From af719bba05b3722f9e536101341a58830a5fb3cc Mon Sep 17 00:00:00 2001 From: jonathoncontact Date: Mon, 5 Oct 2026 12:30:03 -0700 Subject: [PATCH] hamburger bar slides. Need to create a user and check if it shows 'my medication' option --- docs/specs/saved-medicines-navigation.md | 55 +++++ web/package.json | 3 +- web/pnpm-lock.yaml | 57 +++++ web/src/App.tsx | 259 ++++++++++++++++------- web/src/main.tsx | 5 +- web/src/styles.css | 180 +++++++++++++++- web/tests/e2e/landing-page.spec.ts | 56 ++++- 7 files changed, 530 insertions(+), 85 deletions(-) create mode 100644 docs/specs/saved-medicines-navigation.md diff --git a/docs/specs/saved-medicines-navigation.md b/docs/specs/saved-medicines-navigation.md new file mode 100644 index 0000000..8356276 --- /dev/null +++ b/docs/specs/saved-medicines-navigation.md @@ -0,0 +1,55 @@ +# Saved Medicines Navigation Specification + +## Objective + +Add sliding navigation to the MedCheck prototype so a user can move between +the existing Home page and, when authenticated, a dedicated Saved Medicines +page. The Saved Medicines page must clearly represent the current no-data state +and provide a list layout that can render medicine records when a future data +source exists. + +This feature does not add a save action, authentication, browser persistence, +a backend API, or a medicine domain model. + +## Routes and Navigation + +- `/` renders the existing MedCheck search prototype. +- `/saved-medicines` renders the Saved Medicines page. +- The hamburger button opens a full-height drawer that slides in from the left. +- The drawer begins beneath the hamburger control and places a backdrop over + the remaining page. +- The drawer always contains Home and only contains Saved Medicines when the + user is authenticated. +- Following a link closes the popover. +- Pressing Escape or the backdrop closes the drawer. Escape returns focus to + the hamburger button. +- The current destination is identified with `aria-current="page"`. +- Logged-out users who load `/saved-medicines` directly are redirected Home. + +## Saved Medicines States + +- With no supplied medicines, show the heading `Saved Medicines` and the + message `No saved medicines yet.` +- When medicine records are supplied in the future, render their names in a + semantic list. +- Do not seed fake medicines or imply that data survives a reload. + +## Testing Strategy + +Playwright covers menu interaction, keyboard dismissal, navigation, direct +route loading, active-page state, and the Saved Medicines empty state. Existing +landing-page search behavior remains covered by its current tests. + +## Boundaries + +- Always keep navigation keyboard-accessible and responsive down to 320px. +- Ask before adding persistence, authentication, APIs, or medical-data sources. +- Never store or log real medicine or account data in this prototype. + +## Success Criteria + +- Home is always reachable from the hamburger menu. +- Saved Medicines is only visible and reachable for authenticated users. +- Authenticated visits to `/saved-medicines` display a meaningful empty state. +- Menu state and focus behavior are accessible by keyboard. +- Formatting, linting, type checking, tests, build, and browser tests pass. diff --git a/web/package.json b/web/package.json index d08e073..d653542 100644 --- a/web/package.json +++ b/web/package.json @@ -20,7 +20,8 @@ }, "dependencies": { "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-router-dom": "^7.18.4" }, "devDependencies": { "@eslint/js": "^10.0.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 024e0d7..5c1a5d4 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -13,6 +13,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.3.0(react@19.3.0) + react-router-dom: + specifier: ^7.18.4 + version: 7.18.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0) devDependencies: "@eslint/js": specifier: ^10.0.0 @@ -1534,6 +1537,13 @@ packages: integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==, } + cookie@1.1.1: + resolution: + { + integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==, + } + engines: { node: ">=18" } + create-jest@29.7.0: resolution: { @@ -2707,6 +2717,29 @@ packages: integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==, } + react-router-dom@7.18.4: + resolution: + { + integrity: sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==, + } + engines: { node: ">=20.0.0" } + peerDependencies: + react: ">=18" + react-dom: ">=18" + + react-router@7.18.4: + resolution: + { + integrity: sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==, + } + engines: { node: ">=20.0.0" } + peerDependencies: + react: ">=18" + react-dom: ">=18" + peerDependenciesMeta: + react-dom: + optional: true + react@19.3.0: resolution: { @@ -2779,6 +2812,12 @@ packages: engines: { node: ">=10" } hasBin: true + set-cookie-parser@2.7.2: + resolution: + { + integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==, + } + shebang-command@2.0.0: resolution: { @@ -4076,6 +4115,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie@1.1.1: {} + create-jest@29.7.0(@types/node@26.6.2): dependencies: "@jest/types": 29.6.3 @@ -4914,6 +4955,20 @@ snapshots: react-is@18.3.1: {} + react-router-dom@7.18.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + dependencies: + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + react-router: 7.18.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + + react-router@7.18.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + dependencies: + cookie: 1.1.1 + react: 19.3.0 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.3.0(react@19.3.0) + react@19.3.0: {} require-directory@2.1.1: {} @@ -4971,6 +5026,8 @@ snapshots: semver@7.8.5: {} + set-cookie-parser@2.7.2: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 diff --git a/web/src/App.tsx b/web/src/App.tsx index f286ea0..1167d90 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,11 +1,116 @@ -import { FormEvent, useState } from "react"; +import { FormEvent, useEffect, useRef, useState } from "react"; +import { Navigate, NavLink, Route, Routes } from "react-router-dom"; type OpenPanel = "menu" | "account" | null; -export function App() { +export type SavedMedicine = { + id: string; + name: string; +}; + +type AppProps = { + isAuthenticated?: boolean; + savedMedicines?: SavedMedicine[]; +}; + +function Navigation({ isAuthenticated }: { isAuthenticated: boolean }) { + const [openPanel, setOpenPanel] = useState(null); + const menuButtonRef = useRef(null); + + useEffect(() => { + if (openPanel !== "menu") { + return; + } + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + function closeMenu(event: KeyboardEvent) { + if (event.key === "Escape") { + setOpenPanel(null); + menuButtonRef.current?.focus(); + } + } + + document.addEventListener("keydown", closeMenu); + return () => { + document.removeEventListener("keydown", closeMenu); + document.body.style.overflow = previousOverflow; + }; + }, [openPanel]); + + return ( +
+ + + + {openPanel === "menu" && ( + + + )} +
+ ); +} + +function HomePage() { const [query, setQuery] = useState(""); const [submittedQuery, setSubmittedQuery] = useState(""); - const [openPanel, setOpenPanel] = useState(null); function handleSubmit(event: FormEvent) { event.preventDefault(); @@ -17,85 +122,89 @@ export function App() { } return ( -
- +function SavedMedicinesPage({ medicines }: { medicines: SavedMedicine[] }) { + return ( +
+
+

Your library

+

Saved Medicines

+

Medicines you save will be collected here for quick reference.

+
-
-

MedCheck

-

- Your simple starting point for health information. -

- -
- - setQuery(event.target.value)} - placeholder="Search medications, symptoms, or health topics" - autoComplete="off" - /> - -
+ {medicines.length === 0 ? ( +
+ +

No saved medicines yet.

+

Return Home to search when you are ready to build your list.

+ + Return Home + +
+ ) : ( +
    + {medicines.map((medicine) => ( +
  • {medicine.name}
  • + ))} +
+ )} +
+ ); +} -

- {submittedQuery ? `Searching for “${submittedQuery}”` : ""} -

-
+export function App({ + isAuthenticated = false, + savedMedicines = [], +}: AppProps) { + return ( +
+ + + } /> + + ) : ( + + ) + } + /> +
); } diff --git a/web/src/main.tsx b/web/src/main.tsx index 15c5a1c..307de07 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,11 +1,14 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; import { App } from "./App"; import "./styles.css"; createRoot(document.getElementById("root")!).render( - + + + , ); diff --git a/web/src/styles.css b/web/src/styles.css index 18000fd..7acbacd 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -28,7 +28,7 @@ input { font: inherit; } -.landing-page { +.app-shell { position: relative; display: grid; min-height: 100vh; @@ -38,6 +38,7 @@ input { .top-bar { position: absolute; + z-index: 30; top: 1.5rem; right: 1.5rem; left: 1.5rem; @@ -60,12 +61,67 @@ input { text-align: left; } -.menu-panel { +.account-panel { + right: 0; +} + +.drawer-backdrop { + position: fixed; + z-index: 10; + inset: 0; + border: 0; + background: rgb(29 51 43 / 18%); + cursor: default; +} + +.menu-drawer { + position: fixed; + z-index: 20; + top: 0; + bottom: 0; left: 0; + display: flex; + width: min(18rem, 82vw); + flex-direction: column; + gap: 0.25rem; + padding: 5.75rem 1.5rem 1.5rem; + border-right: 1px solid #c7d2ca; + background: #fffefa; + box-shadow: 0.75rem 0 2rem rgb(44 72 57 / 12%); + transform: translateX(-100%); + visibility: hidden; + transition: + transform 240ms ease, + visibility 0s linear 240ms; } -.account-panel { - right: 0; +.menu-drawer[data-open="true"] { + transform: translateX(0); + visibility: visible; + transition-delay: 0s; +} + +.menu-drawer .popover-title { + margin-bottom: 0.75rem; +} + +.menu-drawer a { + border-radius: 0.375rem; + color: #1d332b; + padding: 0.75rem; + font-weight: 650; + text-decoration: none; +} + +.menu-drawer a:hover, +.menu-drawer a:focus-visible, +.menu-drawer a[aria-current="page"] { + background: #edf3ee; + outline: 0; +} + +.menu-drawer a[aria-current="page"] { + color: #24563f; } .popover-title { @@ -109,6 +165,11 @@ input { color 160ms ease; } +.top-bar > .icon-button:first-child { + position: relative; + z-index: 30; +} + .icon-button:hover { border-color: #c7d2ca; background: #fffefa; @@ -243,6 +304,103 @@ h1 { font-size: 0.875rem; } +.saved-medicines { + width: min(100%, 50rem); + padding: 6rem 0 3rem; +} + +.saved-medicines-heading { + max-width: 38rem; +} + +.section-label { + margin: 0 0 0.75rem; + color: #2f6b4f; + font-size: 0.75rem; + font-weight: 750; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.saved-medicines h1 { + font-size: clamp(2.5rem, 7vw, 4.5rem); +} + +.saved-medicines-heading > p:last-child { + margin: 1rem 0 0; + color: #60716b; + font-size: 1.0625rem; + line-height: 1.6; +} + +.empty-state { + display: grid; + justify-items: start; + margin-top: 3rem; + padding: 2rem 0; + border-top: 1px solid #c7d2ca; + border-bottom: 1px solid #c7d2ca; +} + +.empty-state-icon { + display: grid; + width: 2.75rem; + height: 2.75rem; + place-items: center; + border-radius: 50%; + background: #dce9df; + color: #2f6b4f; + font-size: 1.75rem; + font-weight: 400; + line-height: 1; +} + +.empty-state h2 { + margin: 1.25rem 0 0; + color: #1d332b; + font-size: 1.25rem; +} + +.empty-state p { + margin: 0.5rem 0 0; + color: #60716b; + line-height: 1.5; +} + +.home-link { + margin-top: 1.5rem; + border-bottom: 1px solid currentColor; + color: #2f6b4f; + font-weight: 700; + text-decoration: none; +} + +.home-link:hover { + color: #24563f; +} + +.home-link:focus-visible { + border-radius: 0.25rem; + outline: 3px solid #9bc4a7; + outline-offset: 0.25rem; +} + +.medicine-list { + display: grid; + gap: 0; + margin: 3rem 0 0; + padding: 0; + border-top: 1px solid #c7d2ca; + list-style: none; +} + +.medicine-list li { + padding: 1rem 0; + border-bottom: 1px solid #c7d2ca; + color: #1d332b; + font-weight: 650; +} + .sr-only { position: absolute; width: 1px; @@ -255,7 +413,7 @@ h1 { } @media (max-width: 480px) { - .landing-page { + .app-shell { padding: 1rem; } @@ -265,6 +423,10 @@ h1 { left: 1rem; } + .menu-drawer { + padding: 5rem 1rem 1rem; + } + .tagline { margin-bottom: 1.75rem; } @@ -276,4 +438,12 @@ h1 { .search-form input { font-size: 0.9375rem; } + + .saved-medicines { + padding-top: 5rem; + } + + .empty-state { + margin-top: 2rem; + } } diff --git a/web/tests/e2e/landing-page.spec.ts b/web/tests/e2e/landing-page.spec.ts index 52202ca..33b23d5 100644 --- a/web/tests/e2e/landing-page.spec.ts +++ b/web/tests/e2e/landing-page.spec.ts @@ -52,18 +52,68 @@ test("does not submit an empty search", async ({ page }) => { await expect(page.getByRole("status")).toHaveText(""); }); -test("opens the menu and account panels", async ({ page }) => { +test("opens the sliding navigation drawer and account panel", async ({ + page, +}) => { await page.goto("/"); const menuButton = page.getByRole("button", { name: "Open menu" }); const accountButton = page.getByRole("button", { name: "Account" }); + const drawer = page.getByRole("navigation", { name: "Menu" }); + await expect(drawer).toBeHidden(); await menuButton.click(); - await expect(page.getByText("Menu", { exact: true })).toBeVisible(); + await expect(drawer).toBeVisible(); + await expect(page.getByRole("link", { name: "Home" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Saved Medicines" })).toHaveCount( + 0, + ); + await expect(page.getByRole("button", { name: "Close menu" })).toBeVisible(); await expect(menuButton).toHaveAttribute("aria-expanded", "true"); + await page.getByRole("button", { name: "Close menu" }).click({ + position: { x: 400, y: 200 }, + }); await accountButton.click(); await expect(page.getByText("Account", { exact: true })).toBeVisible(); - await expect(page.getByText("Menu", { exact: true })).toHaveCount(0); + await expect(drawer).toBeHidden(); await expect(accountButton).toHaveAttribute("aria-expanded", "true"); }); + +test("closes the navigation menu with Escape and restores focus", async ({ + page, +}) => { + await page.goto("/"); + + const menuButton = page.getByRole("button", { name: "Open menu" }); + await menuButton.click(); + await page.keyboard.press("Escape"); + + await expect(page.getByRole("navigation", { name: "Menu" })).toBeHidden(); + await expect(menuButton).toBeFocused(); + await expect(menuButton).toHaveAttribute("aria-expanded", "false"); +}); + +test("closes the navigation drawer from its backdrop", async ({ page }) => { + await page.goto("/"); + + await page.getByRole("button", { name: "Open menu" }).click(); + await page.getByRole("button", { name: "Close menu" }).click({ + position: { x: 400, y: 200 }, + }); + + await expect(page.getByRole("navigation", { name: "Menu" })).toBeHidden(); +}); + +test("redirects logged-out users away from Saved Medicines", async ({ + page, +}) => { + await page.goto("/saved-medicines"); + + await expect(page).toHaveURL("/"); + await expect(page.getByRole("heading", { name: "MedCheck" })).toBeVisible(); + await page.getByRole("button", { name: "Open menu" }).click(); + await expect(page.getByRole("link", { name: "Saved Medicines" })).toHaveCount( + 0, + ); +});