diff --git a/jest.config.mjs b/jest.config.mjs index 25552207926f..3540d474c0f3 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -3,11 +3,12 @@ export default { testEnvironment: "node", setupFiles: ["./src/setupTests.js"], transform: { - "^.+\\.jsx?$": "babel-jest", + "^.+\\.(m|c)?jsx?$": "babel-jest", }, moduleNameMapper: { "\\.(scss|css)$": "/src/components/__mocks__/styleMock.js", "\\.svg$": "/src/components/__mocks__/svgMock.js", + "\\.(png|jpg|jpeg|ico)$": "/src/components/__mocks__/fileMock.js", }, moduleFileExtensions: [ "js", diff --git a/src/components/SidebarMobile/SidebarMobile.jsx b/src/components/SidebarMobile/SidebarMobile.jsx index 812f01b1c9c8..cc6cb65cd002 100644 --- a/src/components/SidebarMobile/SidebarMobile.jsx +++ b/src/components/SidebarMobile/SidebarMobile.jsx @@ -1,99 +1,162 @@ import { clsx } from "clsx"; import PropTypes from "prop-types"; -import { Component } from "react"; +import { useCallback, useEffect, useRef } from "react"; import CloseIcon from "../../styles/icons/cross.svg"; import Link from "../Link/Link.jsx"; // TODO: Check to make sure all pages are shown and properly sorted -export default class SidebarMobile extends Component { - _container = null; +export default function SidebarMobile({ isOpen, toggle, sections }) { + const containerRef = useRef(null); + const openerRef = useRef(null); + const initialTouchPosition = useRef({}); + const lastTouchPosition = useRef({}); + const isOpenRef = useRef(isOpen); + const toggleRef = useRef(toggle); + + useEffect(() => { + isOpenRef.current = isOpen; + }, [isOpen]); + + useEffect(() => { + toggleRef.current = toggle; + }, [toggle]); + + useEffect(() => { + if (!isOpen) return; + + const handleBodyClick = (event) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target) + ) { + toggleRef.current(false); + } + }; + + window.addEventListener("touchstart", handleBodyClick); + window.addEventListener("mousedown", handleBodyClick); + + return () => { + window.removeEventListener("touchstart", handleBodyClick); + window.removeEventListener("mousedown", handleBodyClick); + }; + }, [isOpen]); + + const handleTouchStart = useCallback((event) => { + initialTouchPosition.current.x = event.touches[0].pageX; + initialTouchPosition.current.y = event.touches[0].pageY; - _initialTouchPosition = {}; + // For instant transform along with the touch + containerRef.current.classList.add("!duration-0"); + }, []); - _lastTouchPosition = {}; + const handleTouchEnd = useCallback((event) => { + const threshold = 20; - static propTypes = { - isOpen: PropTypes.bool, - toggle: PropTypes.func, - sections: PropTypes.array, - }; + // Free up all the inline styling + containerRef.current.classList.remove("!duration-0"); + containerRef.current.style.transform = ""; - componentDidMount() { - if (this.props.isOpen) { - this._toggleBodyListener(true); + // are we open? + if ( + isOpenRef.current && + initialTouchPosition.current.x - lastTouchPosition.current.x > threshold + ) { + // this is in top level nav callback + toggleRef.current(false); + } else if ( + !isOpenRef.current && + lastTouchPosition.current.x - initialTouchPosition.current.x > threshold + ) { + toggleRef.current(true); + event.preventDefault(); + event.stopPropagation(); } - } + }, []); + + // Attach touchmove with passive: false so preventDefault works + useEffect(() => { + const container = containerRef.current; + const opener = openerRef.current; + + const handleTouchMove = (event) => { + const xDiff = initialTouchPosition.current.x - event.touches[0].pageX; + const yDiff = initialTouchPosition.current.y - event.touches[0].pageY; + const factor = Math.abs(yDiff / xDiff); + + // Factor makes sure horizontal and vertical scroll dont take place together + if (xDiff > 0 && factor < 0.8) { + event.preventDefault(); + container.style.transform = `translateX(-${xDiff}px)`; + lastTouchPosition.current.x = event.touches[0].pageX; + lastTouchPosition.current.y = event.touches[0].pageY; + } + }; + + const handleOpenerTouchMove = (event) => { + const xDiff = event.touches[0].pageX - initialTouchPosition.current.x; + const yDiff = initialTouchPosition.current.y - event.touches[0].pageY; + const factor = Math.abs(yDiff / xDiff); + + // Factor makes sure horizontal and vertical scroll dont take place together + if (xDiff > 0 && xDiff < 295 && factor < 0.8) { + event.preventDefault(); + container.style.transform = `translateX(calc(-100% + ${xDiff}px))`; + lastTouchPosition.current.x = event.touches[0].pageX; + lastTouchPosition.current.y = event.touches[0].pageY; + } + }; + + container.addEventListener("touchmove", handleTouchMove, { + passive: false, + }); + opener.addEventListener("touchmove", handleOpenerTouchMove, { + passive: false, + }); - componentDidUpdate(prevProps) { - if (prevProps.isOpen !== this.props.isOpen) { - this._toggleBodyListener(this.props.isOpen); - } - } + return () => { + container.removeEventListener("touchmove", handleTouchMove); + opener.removeEventListener("touchmove", handleOpenerTouchMove); + }; + }, []); - componentWillUnmount() { - this._toggleBodyListener(false); - } + const getPages = (pages) => { + let pathname = ""; - render() { - const { isOpen, toggle } = this.props; + if (window.location !== undefined) { + pathname = window.location.pathname; + } - return ( - - ); - } - - _toggleBodyListener = (add) => { - const actionName = add ? "addEventListener" : "removeEventListener"; - window[actionName]("touchstart", this._handleBodyClick); - window[actionName]("mousedown", this._handleBodyClick); + to={url} + onClick={() => toggle(false)} + > + {page.title} + + ); + }); }; - /** - * Get markup for each section - * - * @return {array} - Markup containing sections and links - */ - _getSections() { + const getSections = () => { let pathname = ""; if (window && window.location !== undefined) { pathname = window.location.pathname; } - return this.props.sections.map((section, index) => { + return sections.map((section, index) => { const active = section.url !== "/" && pathname.startsWith(section.url); return ( @@ -113,122 +176,56 @@ export default class SidebarMobile extends Component { )} key={section.url} to={section.url} - onClick={this.props.toggle.bind(null, false)} + onClick={() => toggle(false)} >

{section.title || section.url}

- {this._getPages(section.children)} + {getPages(section.children)} ); }); - } - - /** - * Retrieve markup for page links - * - * @param {array} pages - A list of page objects - * @return {array} - Markup containing the page links - */ - _getPages(pages) { - let pathname = ""; - - if (window.location !== undefined) { - pathname = window.location.pathname; - } - - return pages.map((page) => { - const url = `${page.url}`; - const active = pathname === url; - - return ( - - {page.title} - - ); - }); - } - - /** - * Handle clicks on content - * - * @param {object} event - Native click event - */ - _handleBodyClick = (event) => { - const { isOpen, toggle } = this.props; - if (isOpen && this._container && !this._container.contains(event.target)) { - toggle(false); - } - }; - - _handleTouchStart = (event) => { - this._initialTouchPosition.x = event.touches[0].pageX; - this._initialTouchPosition.y = event.touches[0].pageY; - - // For instant transform along with the touch - this._container.classList.add("!duration-0"); }; - _handleTouchMove = (event) => { - const xDiff = this._initialTouchPosition.x - event.touches[0].pageX; - const yDiff = this._initialTouchPosition.y - event.touches[0].pageY; - const factor = Math.abs(yDiff / xDiff); - - // Factor makes sure horizontal and vertical scroll dont take place together - if (xDiff > 0 && factor < 0.8) { - event.preventDefault(); - this._container.style.transform = `translateX(-${xDiff}px)`; - this._lastTouchPosition.x = event.touches[0].pageX; - this._lastTouchPosition.y = event.touches[0].pageY; - } - }; - - _handleOpenerTouchMove = (event) => { - const xDiff = event.touches[0].pageX - this._initialTouchPosition.x; - const yDiff = this._initialTouchPosition.y - event.touches[0].pageY; - const factor = Math.abs(yDiff / xDiff); - - // Factor makes sure horizontal and vertical scroll dont take place together - if (xDiff > 0 && xDiff < 295 && factor < 0.8) { - event.preventDefault(); - this._container.style.transform = `translateX(calc(-100% + ${xDiff}px))`; - this._lastTouchPosition.x = event.touches[0].pageX; - this._lastTouchPosition.y = event.touches[0].pageY; - } - }; - - _handleTouchEnd = (event) => { - const { isOpen } = this.props; - const threshold = 20; - - // Free up all the inline styling - this._container.classList.remove("!duration-0"); - this._container.style.transform = ""; + return ( + + ); } + +SidebarMobile.propTypes = { + isOpen: PropTypes.bool, + toggle: PropTypes.func, + sections: PropTypes.array, +}; diff --git a/src/components/SidebarMobile/SidebarMobile.test.jsx b/src/components/SidebarMobile/SidebarMobile.test.jsx new file mode 100644 index 000000000000..bcf367f446e5 --- /dev/null +++ b/src/components/SidebarMobile/SidebarMobile.test.jsx @@ -0,0 +1,97 @@ +/** + * @jest-environment jsdom + */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { describe, expect, it, jest } from "@jest/globals"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import SidebarMobile from "./SidebarMobile.jsx"; + +const sections = [ + { + title: "Guides", + url: "/guides/", + children: [ + { title: "Getting Started", url: "/guides/getting-started/" }, + { title: "Installation", url: "/guides/installation/" }, + ], + }, + { + title: "Concepts", + url: "/concepts/", + children: [{ title: "Entry Points", url: "/concepts/entry-points/" }], + }, +]; + +function renderSidebar(props = {}) { + const toggle = props.toggle || jest.fn(); + const result = render( + + + , + ); + return { ...result, toggle }; +} + +describe("SidebarMobile", () => { + it("renders all section titles", () => { + renderSidebar(); + expect(screen.getByText("Guides")).toBeTruthy(); + expect(screen.getByText("Concepts")).toBeTruthy(); + }); + + it("renders all page links", () => { + renderSidebar(); + expect(screen.getByText("Getting Started")).toBeTruthy(); + expect(screen.getByText("Installation")).toBeTruthy(); + expect(screen.getByText("Entry Points")).toBeTruthy(); + }); + + it("applies visible class when open", () => { + const { container } = renderSidebar({ isOpen: true }); + const nav = container.querySelector("nav"); + expect(nav.className).toContain("sidebar-mobile--visible"); + }); + + it("does not apply visible class when closed", () => { + const { container } = renderSidebar({ isOpen: false }); + const nav = container.querySelector("nav"); + expect(nav.className).not.toContain("sidebar-mobile--visible"); + }); + + it("calls toggle(false) when close button is clicked", () => { + const toggle = jest.fn(); + renderSidebar({ toggle }); + fireEvent.click(screen.getByRole("button", { name: /close navigation/i })); + expect(toggle).toHaveBeenCalledWith(false); + }); + + it("calls toggle(false) when a page link is clicked", () => { + const toggle = jest.fn(); + renderSidebar({ toggle }); + fireEvent.click(screen.getByText("Getting Started")); + expect(toggle).toHaveBeenCalledWith(false); + }); + + it("calls toggle(false) when a section link is clicked", () => { + const toggle = jest.fn(); + renderSidebar({ toggle }); + fireEvent.click(screen.getByText("Guides")); + expect(toggle).toHaveBeenCalledWith(false); + }); + + it("matches snapshot when closed", () => { + const { container } = renderSidebar({ isOpen: false }); + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot when open", () => { + const { container } = renderSidebar({ isOpen: true }); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/src/components/SidebarMobile/__snapshots__/SidebarMobile.test.jsx.snap b/src/components/SidebarMobile/__snapshots__/SidebarMobile.test.jsx.snap new file mode 100644 index 000000000000..968013aa5781 --- /dev/null +++ b/src/components/SidebarMobile/__snapshots__/SidebarMobile.test.jsx.snap @@ -0,0 +1,141 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`SidebarMobile matches snapshot when closed 1`] = ` + +`; + +exports[`SidebarMobile matches snapshot when open 1`] = ` + +`; diff --git a/src/components/__mocks__/fileMock.js b/src/components/__mocks__/fileMock.js new file mode 100644 index 000000000000..54e31a1db96c --- /dev/null +++ b/src/components/__mocks__/fileMock.js @@ -0,0 +1 @@ +module.exports = "file-mock";