Refactored side menu to a separate component.

This commit is contained in:
Jay
2025-06-17 10:20:28 -04:00
parent c0343f2378
commit 2a35da629b
7 changed files with 320 additions and 156 deletions
+126 -3
View File
@@ -1,5 +1,128 @@
describe("SideMenu.cy.tsx", () => { import { useState } from "react";
it("playground", () => { import { LeftMenu, RightMenu } from "../../src/components/SideMenu";
// cy.mount()
// Test Fixtures
function newTestApp(position: "left" | "right") {
const Menu = position === "left" ? LeftMenu : RightMenu;
return function TestApp() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
return (
<div
data-cy="app"
className="appContainer"
style={{ overflowX: "hidden" }}
>
<button
data-cy="toggle-menu"
onClick={() => setIsMenuOpen((prev) => !prev)}
>
Open Menu
</button>
<Menu isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)}>
Menu Contents
</Menu>
<div data-cy="main-content">Main Content</div>
</div>
);
};
}
// Tests
function menuTest(position: "left" | "right") {
const isLeft = position === "left";
// 1. Check initial state
cy.dataCy(`${position}-menu`)
.as("menu")
.should("not.be.visible")
.contains("Menu Contents")
.should("not.be.visible");
// 2. Open menu
cy.dataCy("toggle-menu")
.as("toggle")
.click()
.get("@menu")
.should("be.visible")
.contains("Menu Contents")
.should("be.visible");
// 3. Close menu with button
cy.dataCy("close-menu")
.as("close")
.click()
.get("@menu")
.should("not.be.visible")
.contains("Menu Contents")
.should("not.be.visible");
// 4. Reopen menu, verify interactivity, then close with underlay
cy.get("@toggle")
.click()
.get("@menu")
.should("be.visible")
.click("center")
.get("@menu")
.should("be.visible")
.dataCy("app")
.click(isLeft ? "topRight" : "topLeft")
.get("@menu")
.should("not.be.visible");
}
beforeEach(() => {
cy.disableTransitions();
});
afterEach(() => {
cy.enableTransitions();
});
describe("LeftMenu Tests", () => {
beforeEach(() => {
const TestApp = newTestApp("left");
cy.mount(<TestApp />);
});
afterEach(() => {
cy.enableTransitions();
});
it("works in portrait", () => {
cy.viewport("iphone-6");
menuTest("left");
});
it("works in landscape", () => {
cy.viewport("iphone-6", "landscape");
menuTest("left");
});
});
describe("RightMenu Tests", () => {
beforeEach(() => {
const TestApp = newTestApp("right");
cy.mount(<TestApp />);
});
it("works in portrait", () => {
cy.viewport("iphone-6");
menuTest("right");
});
it("works in landscape", () => {
cy.viewport("iphone-6", "landscape");
menuTest("right");
}); });
}); });
+3 -115
View File
@@ -4,53 +4,7 @@
overflow: hidden; overflow: hidden;
} }
.leftMobileMenu { /* Large */
height: 100vh;
width: 0;
position: fixed;
z-index: 30;
top: 0;
left: 0;
background: white;
overflow-x: hidden;
transition: 0.5s ease-out;
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.2);
}
.leftMenuWrapper {
position: absolute;
right: 0;
}
.leftMobileMenu .topNav {
display: flex;
}
.leftMenuWrapper .closeButton {
margin: 10px 10px 10px auto;
}
.rightMobileMenu {
height: 100vh;
width: 0;
position: fixed;
z-index: 20;
top: 0;
right: 0;
background: white;
overflow-x: hidden;
transition: 0.5s ease-out;
box-shadow: -2px 0 5px rgba(0, 0, 0, 0.2);
}
.rightMenuWrapper {
position: absolute;
left: 0;
}
/* Large - Landscape Tablets / Desktops */
/* Medium - Portrait Tablets */
/* Horizontal layout, vertically scrolling picker and palette content */
@media (min-width: 992px), @media (min-width: 992px),
(min-width: 568px) and (max-width: 991px) and (orientation: portrait) { (min-width: 568px) and (max-width: 991px) and (orientation: portrait) {
.appContainer { .appContainer {
@@ -87,8 +41,7 @@
} }
} }
/* Medium - Landscape Phones */ /* Landscape Phone */
/* Horizontal layout, side menu, vertical tabbed picker */
@media (min-width: 568px) and (max-width: 991px) and (orientation: landscape) { @media (min-width: 568px) and (max-width: 991px) and (orientation: landscape) {
.appContainer { .appContainer {
} }
@@ -116,38 +69,6 @@
flex: 1; flex: 1;
} }
.leftMobileMenu {
}
.leftMobileMenu.open {
width: 80vh;
}
.leftMenuWrapper {
width: 80vh;
}
.leftMobileMenu .topNav {
}
.leftMenuWrapper .closeButton {
}
.rightMobileMenu {
}
.rightMobileMenu.open {
width: 80vh;
}
.rightMenuWrapper {
width: 80vh;
}
.rightMobileMenu .closeButton {
margin: 10px;
}
.tabbedContainer { .tabbedContainer {
max-height: 100vh; max-height: 100vh;
overflow-y: scroll; overflow-y: scroll;
@@ -172,8 +93,7 @@
} }
} }
/* Small - Portrait Phones*/ /* Portrait Phone */
/* Vertical layout, side menu, horizontal tabbed picker */
@media (max-width: 567px) { @media (max-width: 567px) {
.appContainer { .appContainer {
} }
@@ -205,38 +125,6 @@
.mobileBetaZone { .mobileBetaZone {
} }
.leftMobileMenu {
}
.leftMobileMenu.open {
width: 80%;
}
.leftMenuWrapper {
width: 80vw;
}
.leftMobileMenu .topNav {
}
.leftMenuWrapper .closeButton {
}
.rightMobileMenu {
}
.rightMobileMenu.open {
width: 80%;
}
.rightMenuWrapper {
width: 80vw;
}
.rightMobileMenu .closeButton {
margin: 10px;
}
.tabbedContainer { .tabbedContainer {
display: flex; display: flex;
overflow-x: scroll; overflow-x: scroll;
+22 -31
View File
@@ -2,14 +2,15 @@ import { useState } from "react";
import styles from "./App.module.css"; import styles from "./App.module.css";
import ColorPicker from "./components/ColorPicker/ColorPicker"; import ColorPicker from "./components/ColorPicker/ColorPicker";
import ColorValues from "./components/ColorValues/ColorValues"; import ColorValues from "./components/ColorValues/ColorValues";
import { LeftMenu, RightMenu } from "./components/SideMenu";
import clsx from "clsx"; import clsx from "clsx";
function App() { function App() {
const [rightMenuOpen, setRightMenuOpen] = useState(false); const [isRightMenuOpen, setIsRightMenuOpen] = useState(false);
const [leftMenuOpen, setLeftMenuOpen] = useState(false); const [isLeftMenuOpen, setIsLeftMenuOpen] = useState(false);
const toggleRightMenu = () => setRightMenuOpen(!rightMenuOpen); const toggleRightMenu = () => setIsRightMenuOpen(!isRightMenuOpen);
const toggleLeftMenu = () => setLeftMenuOpen(!leftMenuOpen); const toggleLeftMenu = () => setIsLeftMenuOpen(!isLeftMenuOpen);
const RightMenuButton = () => ( const RightMenuButton = () => (
<button className={styles.rightMenuButton} onClick={toggleRightMenu}> <button className={styles.rightMenuButton} onClick={toggleRightMenu}>
@@ -29,9 +30,11 @@ function App() {
<LeftMenuButton /> <LeftMenuButton />
<RightMenuButton /> <RightMenuButton />
</div> </div>
<div className={styles.mobileLeftNav}> <div className={styles.mobileLeftNav}>
<LeftMenuButton /> <LeftMenuButton />
</div> </div>
<div className={styles.mobileAlphaZone}> <div className={styles.mobileAlphaZone}>
<div className={styles.tabbedContainer}> <div className={styles.tabbedContainer}>
<div className={clsx(styles.tab, styles.colorPickerContainer)}> <div className={clsx(styles.tab, styles.colorPickerContainer)}>
@@ -42,42 +45,28 @@ function App() {
</div> </div>
</div> </div>
</div> </div>
<div className={styles.mobileBetaZone}> <div className={styles.mobileBetaZone}>
<div className={styles.paletteEditorContainer}></div> <div className={styles.paletteEditorContainer}></div>
</div> </div>
<div className={styles.mobileRightNav}> <div className={styles.mobileRightNav}>
<RightMenuButton /> <RightMenuButton />
</div> </div>
</div>
<div <LeftMenu
className={clsx(styles.leftMobileMenu, { isOpen={isLeftMenuOpen}
[styles.open]: leftMenuOpen, onClose={() => setIsLeftMenuOpen(false)}
})} >
> <div>User Info</div>
<div className={styles.leftMenuWrapper}> </LeftMenu>
<div className={styles.topNav}>
<button className={styles.closeButton} onClick={toggleLeftMenu}>
×
</button>
</div>
<div className={styles.paletteLibraryContainer}>User Info</div>
</div>
</div>
<div <RightMenu
className={clsx(styles.rightMobileMenu, { isOpen={isRightMenuOpen}
[styles.open]: rightMenuOpen, onClose={() => setIsRightMenuOpen(false)}
})} >
>
<div className={styles.rightMenuWrapper}>
<div className={styles.topNav}>
<button className={styles.closeButton} onClick={toggleRightMenu}>
×
</button>
</div>
<div className={styles.paletteLibraryContainer}>Palette Library</div> <div className={styles.paletteLibraryContainer}>Palette Library</div>
</div> </RightMenu>
</div> </div>
<div className={styles.mainContent}> <div className={styles.mainContent}>
@@ -85,10 +74,12 @@ function App() {
<div className={styles.colorPickerContainer}> <div className={styles.colorPickerContainer}>
<ColorPicker /> <ColorPicker />
</div> </div>
<div className={styles.colorValuesContainer}> <div className={styles.colorValuesContainer}>
<ColorValues /> <ColorValues />
</div> </div>
</div> </div>
<div className={styles.betaZone}> <div className={styles.betaZone}>
<div className={styles.paletteEditorContainer}></div> <div className={styles.paletteEditorContainer}></div>
<div className={styles.paletteLibraryContainer}></div> <div className={styles.paletteLibraryContainer}></div>
@@ -0,0 +1,88 @@
.sideMenu {
height: 100vh;
width: 0;
position: fixed;
z-index: 20;
top: 0;
background: white;
overflow-x: hidden;
transition: 0.3s ease-out;
}
.sideMenuUnderlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
background: rgba(0, 0, 0, 0);
transition: background-color 0.3s;
pointer-events: none;
}
.sideMenuUnderlay.open {
background: rgba(0, 0, 0, 0.2);
pointer-events: auto;
}
.left.sideMenu {
left: 0;
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.2);
}
.right.sideMenu {
right: 0;
box-shadow: -2px 0 5px rgba(0, 0, 0, 0.2);
}
.menuWrapper {
position: absolute;
}
.left.menuWrapper {
right: 0;
}
.right.menuWrapper {
left: 0;
}
.topNav {
width: 100%;
display: flex;
}
.right.sideMenu .closeButton {
margin: 10px;
}
.left.sideMenu .closeButton {
margin: 10px 10px 10px auto;
}
/* Landscape Phone */
@media (min-width: 568px) and (max-width: 991px) and (orientation: landscape) {
.left.sideMenu.open,
.left.menuWrapper {
width: 80vh;
}
.right.sideMenu.open,
.right.menuWrapper {
width: 80vh;
}
}
/* Portrait Phone */
@media (max-width: 567px) {
.left.sideMenu.open,
.left.menuWrapper {
width: 80vw;
}
.right.sideMenu.open,
.right.menuWrapper {
width: 80vw;
}
}
+73
View File
@@ -0,0 +1,73 @@
import type { ReactNode } from "react";
import clsx from "clsx";
import styles from "./SideMenu.module.css";
interface SideMenuProps {
isOpen: boolean;
onClose: () => void;
children: ReactNode;
}
interface BaseMenuProps extends SideMenuProps {
position: "left" | "right";
}
function BaseMenu({ isOpen, onClose, children, position }: BaseMenuProps) {
const isLeftMenu = position === "left";
const handleUnderlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
onClose();
}
};
return (
<div
className={clsx(styles.sideMenuUnderlay, { [styles.open]: isOpen })}
onClick={handleUnderlayClick}
>
<div
data-cy={`${position}-menu`}
className={clsx(
styles.sideMenu,
isLeftMenu ? styles.left : styles.right,
{ [styles.open]: isOpen },
)}
onClick={(e) => e.stopPropagation()}
>
<div
className={clsx(
styles.menuWrapper,
isLeftMenu ? styles.left : styles.right,
)}
>
<div className={styles.topNav}>
<button
data-cy="close-menu"
className={styles.closeButton}
onClick={onClose}
>
×
</button>
</div>
{children}
</div>
</div>
</div>
);
}
function newSideMenu(position: "left" | "right") {
return function SideMenu({ isOpen, onClose, children }: SideMenuProps) {
return (
<BaseMenu isOpen={isOpen} onClose={onClose} position={position}>
{children}
</BaseMenu>
);
};
}
const LeftMenu = newSideMenu("left");
const RightMenu = newSideMenu("right");
export { LeftMenu, RightMenu };
+2
View File
@@ -0,0 +1,2 @@
import { LeftMenu, RightMenu } from "./SideMenu";
export { LeftMenu, RightMenu };
+6 -7
View File
@@ -1,11 +1,10 @@
import { StrictMode } from 'react' import { StrictMode } from "react";
import { createRoot } from 'react-dom/client' import { createRoot } from "react-dom/client";
import './index.css' import "./index.css";
import App from './App.tsx' import App from "./App.tsx";
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<App /> <App />
</StrictMode>, </StrictMode>,
) );