Skip to content

Commit

Permalink
IGVF-32 Update mobile header (#14)
Browse files Browse the repository at this point in the history
Resize logo on Safari. Change navigation color to brand color on mobile, and logo and hamburger menu to white on mobile. Change the Tailwind CSS config to use “brand-color” instead of “logo-color.”

Converted Tailwind CSS config to use a class for dark mode, and added code to sense the user changing between dark and light mode. Starting to use CSS variables for theming. Will work on theming in the next commit in this branch.

Extract code to manage dark mode Tailwind CSS classes to a library.

Add tests and mocks for the dark-mode manager.

Implement Jest tests for OS dark mode.

Remove forgotten .only() to test for more coverage.

Update package.json for the latest versions of packages. Update comments from this branch’s changes.

Add mobile menu navigation.

Add Cypress tests for the mobile menu.

Correct Cypress mobile menu test.

Only include .container class beyond mobile so that the navigation header can span across the entire viewport width.

Use light and dark themes in Tailwind configuration.

Change dark highlight color

Update navigation colors for mobile.
  • Loading branch information
forresttanaka committed Mar 23, 2022
1 parent 578b665 commit 67e6929
Show file tree
Hide file tree
Showing 16 changed files with 804 additions and 208 deletions.
6 changes: 3 additions & 3 deletions components/logo.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const Logo = () => {
width="100%"
height="100%"
>
<g className="fill-black dark:fill-white">
<g className="fill-white dark:fill-white md:fill-black md:dark:fill-white">
<path
d="M562.1,41.6h-48.9c-5.7,0-10,4.5-10,10.3v97c0,5.7,4.5,10.3,10.3,10.3c6,0,10.3-4.6,10.3-10.3V60h38.3
c5.3,0,9.4-4.1,9.4-9.1C571.6,45.7,567.5,41.6,562.1,41.6z"
Expand All @@ -31,7 +31,7 @@ export const Logo = () => {
<path d="M223.2,67.5v81.5c0,5.7,4.5,10.3,10.3,10.3c6,0,10.3-4.6,10.3-10.3V67.5l-10.3,10.3L223.2,67.5z" />
<path d="M233.5,40.7c-5.8,0-10.3,4.5-10.3,10.3v6.5l10.3,10.3l10.3-10.3v-6.5C243.8,45.2,239.5,40.7,233.5,40.7z" />
</g>
<g className="fill-logo-color">
<g className="fill-white md:fill-brand">
<circle cx="543.9" cy="102.3" r="9" />
<circle cx="322.1" cy="102.3" r="9" />
<path
Expand All @@ -58,7 +58,7 @@ export const Logo = () => {
const SiteLogo = () => {
return (
<Link href="/">
<a className="block px-8 py-2 md:h-24">
<a className="block w-32 py-2 md:h-24 md:w-auto md:px-8">
<Logo />
<span className="sr-only">Home</span>
</a>
Expand Down
114 changes: 88 additions & 26 deletions components/navigation.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,34 @@
// node_modules
import { motion, AnimatePresence } from "framer-motion"
import Link from "next/link"
import { useRouter } from "next/router"
import PropTypes from "prop-types"
import React from "react"
// components
import SiteLogo from "../components/logo"

/**
* Contains each navigation item. It might get extensions to allow hierarchical navigation.
* `testid` must have a unique value for each item.
*/
const navigationItems = [
{
title: "Awards",
href: "/awards",
testid: "awards",
},
{
title: "Labs",
href: "/labs",
testid: "labs",
},
{
title: "Users",
href: "/users",
testid: "users",
},
]

/**
* Renders the hamburger icon SVG. Click handling gets handled by the parent component.
*/
Expand All @@ -15,7 +39,6 @@ const HamburgerIcon = () => {
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
Expand All @@ -30,16 +53,26 @@ const HamburgerIcon = () => {
/**
* Renders a single navigation item.
*/
const NavigationItem = ({ href, testid, children }) => {
const NavigationItem = ({ href, testid, navigationClick, children }) => {
const router = useRouter()

const onClick = () => {
// Notify the main navigation component that the user has clicked a navigation item, then
// navigate to the href for the navigation item.
navigationClick()
router.push(href)
}

return (
<li>
<Link href={href}>
<a
<Link href={href} passHref>
<button
onClick={onClick}
data-testid={testid}
className="block px-2 py-2 no-underline hover:bg-gray-100 dark:text-white dark:hover:bg-gray-800"
className="block w-full px-2 py-2 text-left text-white no-underline hover:bg-nav-highlight md:text-base md:hover:bg-highlight"
>
{children}
</a>
</button>
</Link>
</li>
)
Expand All @@ -50,14 +83,16 @@ NavigationItem.propTypes = {
href: PropTypes.string.isRequired,
// Searchable test ID for <a>
testid: PropTypes.string,
// Function to call when user clicks a navigation item
navigationClick: PropTypes.func.isRequired,
}

/**
* Wraps the navigation items in <nav> and <ul> tags.
*/
const NavigationList = ({ children }) => {
return (
<nav className="px-8">
<nav className="p-4">
<ul>{children}</ul>
</nav>
)
Expand All @@ -66,49 +101,76 @@ const NavigationList = ({ children }) => {
/**
* Renders the navigation area for mobile and desktop.
*/
const Navigation = () => {
const Navigation = ({ navigationClick }) => {
return (
<NavigationList>
<NavigationItem href="/awards" testid="awards">
Awards
</NavigationItem>
<NavigationItem href="/labs" testid="labs">
Labs
</NavigationItem>
<NavigationItem href="/users" testid="users">
Users
</NavigationItem>
{navigationItems.map((item) => (
<NavigationItem
key={item.testid}
href={item.href}
testid={item.testid}
navigationClick={navigationClick}
>
{item.title}
</NavigationItem>
))}
</NavigationList>
)
}

Navigation.propTypes = {
// Function to call when user clicks a navigation item
navigationClick: PropTypes.func.isRequired,
}

/**
* Displays the navigation bar (for mobile) or the sidebar navigation (for desktop).
*/
const NavigationSection = () => {
// True if user has opened the mobile menu
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false)

/**
* Called when the user clicks a navigation menu item.
*/
const navigationClick = () => {
setIsMobileMenuOpen(false)
}

return (
<section className="md:block md:h-auto md:w-60 md:shrink-0 md:grow-0 md:basis-60">
<div className="flex h-14 justify-between md:block">
<section className="bg-brand md:block md:h-auto md:w-60 md:shrink-0 md:grow-0 md:basis-60 md:bg-transparent">
<div className="flex h-14 justify-between px-4 md:block">
<SiteLogo />
<button
className="md:hidden"
data-testid="mobile-navigation-trigger"
className="stroke-white md:hidden"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
<HamburgerIcon />
</button>
<div className="hidden md:block">
<Navigation />
<Navigation navigationClick={navigationClick} />
</div>
</div>

{isMobileMenuOpen && (
<div className="md:hidden">
<Navigation />
</div>
)}
<AnimatePresence>
{isMobileMenuOpen && (
<motion.div
data-testid="mobile-navigation"
className="overflow-hidden md:hidden"
initial="collapsed"
animate="open"
exit="collapsed"
transition={{ duration: 0.2, ease: "easeInOut" }}
variants={{
open: { height: "auto" },
collapsed: { height: 0 },
}}
>
<Navigation navigationClick={navigationClick} />
</motion.div>
)}
</AnimatePresence>
</section>
)
}
Expand Down
2 changes: 1 addition & 1 deletion components/page-title.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Show a standard page title for the top of any page.
*/
const PageTitle = ({ children }) => {
return <h1 className="text-4xl font-thin dark:text-white">{children}</h1>
return <h1 className="text-4xl font-thin">{children}</h1>
}

export default PageTitle
17 changes: 17 additions & 0 deletions cypress/integration/mobile-menu.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/// <reference types="cypress" />

describe("Mobile menu tests", () => {
it("should open and close the mobile menu", () => {
cy.visit("/")
cy.viewport("iphone-xr")

// The mobile menu should not exist, then open when the user clicks the navigation trigger.
cy.get("[data-testid='mobile-navigation']").should("not.exist")
cy.get("[data-testid='mobile-navigation-trigger']").click()
cy.get("[data-testid='mobile-navigation']").should("be.visible")

// Selecting a mobile navigation item should close the menu.
cy.get("[data-testid=awards]").eq(1).click()
cy.get("[data-testid='mobile-navigation']").should("not.exist")
})
})
127 changes: 127 additions & 0 deletions libs/__mocks__/media-query-mock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* This module allows you to mock the matchMedia event API. Use it to test your code that adds event
* listeners for media queries.
*
* Adapted from:
* https://github.com/dyakovk/jest-matchmedia-mock
*/
import { jest } from "@jest/globals"

export default class MatchMediaMock {
#mediaQueries = {}
#mediaQueryList
#currentMediaQuery

constructor(initialQuery = "(prefers-color-scheme: light)") {
this.#currentMediaQuery = initialQuery

// Add the window.matchMedia() mock.
Object.defineProperty(window, "matchMedia", {
writable: true,
configurable: true,
value: jest.fn().mockImplementation((query) => {
return {
matches: query === this.#currentMediaQuery,
media: query,
onchange: null,
addEventListener: (type, listener) => {
if (type === "change") {
this.#addListener(query, listener)
}
},
removeEventListener: (type, listener) => {
if (type === "change") {
this.#removeListener(query, listener)
}
},
dispatchEvent: jest.fn(),
}
}),
})
}

/**
* Adds a new event-listener function for the specified media query.
*/
#addListener(mediaQuery, listener) {
if (!this.#mediaQueries[mediaQuery]) {
this.#mediaQueries[mediaQuery] = []
}

const query = this.#mediaQueries[mediaQuery]
const listenerIndex = query.indexOf(listener)

if (listenerIndex === -1) {
query.push(listener)
}
}

/**
* Removes a previously added event-listener function for the specified media query.
*/
#removeListener(mediaQuery, listener) {
if (this.#mediaQueries[mediaQuery]) {
const query = this.#mediaQueries[mediaQuery]
const listenerIndex = query.indexOf(listener)
if (listenerIndex !== -1) {
query.splice(listenerIndex, 1)
}
}
}

/**
* Updates the currently used media query and calls previously added listener functions
* registered for this media query.
*/
useMediaQuery(mediaQuery, isMatch) {
if (typeof mediaQuery !== "string") {
throw new Error("Media Query must be a string")
}

this.#currentMediaQuery = mediaQuery

if (this.#mediaQueries[mediaQuery]) {
const mqListEvent = {
matches: isMatch,
media: mediaQuery,
}

this.#mediaQueries[mediaQuery].forEach((listener) => {
listener.call(this.#mediaQueryList, mqListEvent)
})
}
}

/**
* Returns an array listing the media queries for which the matchMedia has registered listeners.
*/
getMediaQueries() {
return Object.keys(this.#mediaQueries)
}

/**
* Returns a copy of the array of listeners for the specified media query.
*/
getListeners(mediaQuery) {
if (this.#mediaQueries[mediaQuery]) {
return this.#mediaQueries[mediaQuery].slice()
}
return []
}

/**
* Clears all registered media queries and their listeners.
*/
clear() {
this.#mediaQueries = {}
}

/**
* Clears all registered media queries and their listeners, and destroys the implementation of
* `window.matchMedia`.
*/
destroy() {
this.clear()
delete window.matchMedia
}
}
5 changes: 5 additions & 0 deletions libs/__tests__/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"env": {
"jest": true
}
}
Loading

0 comments on commit 67e6929

Please sign in to comment.