From 04b7e4dafdcc19fffd795cab3d52c4d39c825bac Mon Sep 17 00:00:00 2001 From: Kevin Duong Date: Sat, 3 Oct 2026 16:55:48 -0400 Subject: [PATCH 1/6] feat(app): Add draft switcher for mobile users --- .../__tests__/mobileDraftSwitcher.test.tsx | 59 ++++ .../contribute/steps/guide/GuideInfo.tsx | 11 +- app/src/components/sidebar/EditorSidebar.tsx | 271 +++++++++++------- .../sidebar/MobileDraftSwitcher.tsx | 104 +++++++ 4 files changed, 335 insertions(+), 110 deletions(-) create mode 100644 app/src/components/__tests__/mobileDraftSwitcher.test.tsx create mode 100644 app/src/components/sidebar/MobileDraftSwitcher.tsx diff --git a/app/src/components/__tests__/mobileDraftSwitcher.test.tsx b/app/src/components/__tests__/mobileDraftSwitcher.test.tsx new file mode 100644 index 00000000..49eff478 --- /dev/null +++ b/app/src/components/__tests__/mobileDraftSwitcher.test.tsx @@ -0,0 +1,59 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { MobileDraftSwitcher } from "@/components/sidebar/MobileDraftSwitcher"; +import { TooltipProvider } from "@/components/ui/tooltip"; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +const guides = [ + { localDraftId: "a", title: "Intro to Limits" }, + { localDraftId: "b", title: "" }, +]; + +const renderSwitcher = (onSelectGuide = vi.fn()) => + render( + + {}} + onDeleteGuide={() => {}} + /> + + ); + +describe("MobileDraftSwitcher", () => { + it("shows the active draft position and a fallback title", () => { + renderSwitcher(); + + const trigger = screen.getByRole("button", { + name: "Drafts: guide 2 of 2, Guide 2", + }); + expect(trigger.textContent).toContain("2 / 2"); + }); + + it("selects a draft from the sheet and closes it", () => { + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + } + ); + const onSelectGuide = vi.fn(); + renderSwitcher(onSelectGuide); + + fireEvent.click(screen.getByRole("button", { name: /^Drafts:/ })); + expect(screen.getByRole("dialog")).toBeDefined(); + + fireEvent.click(screen.getByRole("button", { name: /Intro to Limits/ })); + + expect(onSelectGuide).toHaveBeenCalledWith("a"); + expect(screen.queryByRole("dialog")).toBeNull(); + }); +}); diff --git a/app/src/components/contribute/steps/guide/GuideInfo.tsx b/app/src/components/contribute/steps/guide/GuideInfo.tsx index b49994f5..159c90df 100644 --- a/app/src/components/contribute/steps/guide/GuideInfo.tsx +++ b/app/src/components/contribute/steps/guide/GuideInfo.tsx @@ -8,6 +8,7 @@ import { Content } from "@/components/contribute/steps/Content"; import { CustomTabs } from "@/components/Tabs"; import { GuideDetails } from "@/components/contribute/steps/guide/GuideDetails"; import { EditorSidebar } from "@/components/sidebar/EditorSidebar"; +import { MobileDraftSwitcher } from "@/components/sidebar/MobileDraftSwitcher"; type SubjectOption = { id: string; @@ -137,7 +138,15 @@ export const GuideInfo = ({ onDeleteGuide={onDeleteGuide} /> -
+
+ +
diff --git a/app/src/components/sidebar/EditorSidebar.tsx b/app/src/components/sidebar/EditorSidebar.tsx index eb7a688c..65523fe5 100644 --- a/app/src/components/sidebar/EditorSidebar.tsx +++ b/app/src/components/sidebar/EditorSidebar.tsx @@ -27,7 +27,7 @@ import { } from "@/components/ui/tooltip"; import { SelectDraftModal } from "@/components/modals/SelectDraftModal"; -type GuideOption = Pick & { +export type GuideOption = Pick & { localDraftId: string; }; @@ -50,17 +50,11 @@ export const EditorSidebar = ({ onDeleteGuide, }: Props) => { const [collapsed, setCollapsed] = useState(false); - const [selectDraftModalOpen, setSelectDraftModalOpen] = useState(false); - const [selectedDrafts, setSelectedDrafts] = useState>([]); - - const handleSelectExistingDrafts = (draftIds: Array) => { - setSelectedDrafts(draftIds); - }; return ( + ); +}; + +type DraftActionsProps = { + guideCount: number; + activeGuideId: string; + onAddGuide: () => void; + onDeleteGuide: (localDraftId: string) => void; +}; + +/** + * add (existing / blank) and remove draft buttons + * shared by the desktop sidebar and the mobile drafts sheet + */ +export const DraftActions = ({ + guideCount, + activeGuideId, + onAddGuide, + onDeleteGuide, +}: DraftActionsProps) => { + const [selectDraftModalOpen, setSelectDraftModalOpen] = useState(false); + const [selectedDrafts, setSelectedDrafts] = useState>([]); + + const handleSelectExistingDrafts = (draftIds: Array) => { + setSelectedDrafts(draftIds); + }; + + return ( + <> +
+ + + + + + + - const displayTitle = guide.title.trim() || `Guide ${index + 1}`; + +

Add Draft

+
+
- return ( -
  • - + - {!collapsed && ( - {displayTitle} - )} - + + + + + - -
  • - ); - })} - + + + + + + +

    Remove Draft

    +
    +
    +
    {selectDraftModalOpen && ( )} - + + ); +}; + +type DraftListProps = { + guides: Array; + activeGuideId: string; + collapsed?: boolean; + onSelectGuide: (localDraftId: string) => void; +}; + +export const DraftList = ({ + guides, + activeGuideId, + collapsed = false, + onSelectGuide, +}: DraftListProps) => { + return ( +
      + {guides.map((guide, index) => { + const active = guide.localDraftId === activeGuideId; + + const displayTitle = guide.title.trim() || `Guide ${index + 1}`; + + return ( +
    • + + + +
    • + ); + })} +
    ); }; diff --git a/app/src/components/sidebar/MobileDraftSwitcher.tsx b/app/src/components/sidebar/MobileDraftSwitcher.tsx new file mode 100644 index 00000000..e1d73460 --- /dev/null +++ b/app/src/components/sidebar/MobileDraftSwitcher.tsx @@ -0,0 +1,104 @@ +import { useState } from "react"; +import { PanelLeftOpen } from "lucide-react"; + +import type { GuideOption } from "@/components/sidebar/EditorSidebar"; + +import { Separator } from "@/components/ui/separator"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { DraftActions, DraftList } from "@/components/sidebar/EditorSidebar"; + +type Props = { + guides: Array; + activeGuideId: string; + + onSelectGuide: (localDraftId: string) => void; + onAddGuide: () => void; + onDeleteGuide: (localDraftId: string) => void; +}; + +/** + * mobile replacement for EditorSidebar - a bar showing the active draft + * that opens the draft list in a sheet sliding in from the left + */ +export const MobileDraftSwitcher = ({ + guides, + activeGuideId, + onSelectGuide, + onAddGuide, + onDeleteGuide, +}: Props) => { + const [open, setOpen] = useState(false); + + const activeIndex = guides.findIndex( + (guide) => guide.localDraftId === activeGuideId + ); + const activeTitle = + guides[activeIndex]?.title.trim() || `Guide ${activeIndex + 1}`; + + const selectGuide = (localDraftId: string) => { + onSelectGuide(localDraftId); + setOpen(false); + }; + + const addGuide = () => { + onAddGuide(); + setOpen(false); + }; + + return ( + + + + + + + + + Drafts + + + Switch between, add or remove guide drafts. + + + +
    + +
    + + + +
    + +
    +
    +
    + ); +}; From 4a2091bebd2c9bbdbd8390074ebe782ebcdcf484 Mon Sep 17 00:00:00 2001 From: Kevin Duong Date: Sat, 3 Oct 2026 17:47:29 -0400 Subject: [PATCH 2/6] feat(app): Make guide content blurred when user opens `MobileDraftSwitcher` --- app/src/components/sidebar/MobileDraftSwitcher.tsx | 2 +- app/src/components/ui/sheet.tsx | 2 +- app/src/styles.css | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/components/sidebar/MobileDraftSwitcher.tsx b/app/src/components/sidebar/MobileDraftSwitcher.tsx index e1d73460..4f6c9e57 100644 --- a/app/src/components/sidebar/MobileDraftSwitcher.tsx +++ b/app/src/components/sidebar/MobileDraftSwitcher.tsx @@ -57,7 +57,7 @@ export const MobileDraftSwitcher = ({
    @@ -76,13 +148,7 @@ export const RejectionFeedback = ({ draftId }: { draftId: string }) => {
    - - View the closed case - + diff --git a/app/src/lib/useMediaQuery.ts b/app/src/lib/useMediaQuery.ts new file mode 100644 index 00000000..75e8d9fa --- /dev/null +++ b/app/src/lib/useMediaQuery.ts @@ -0,0 +1,15 @@ +import { useSyncExternalStore } from "react"; + +/** + * tracks whether a CSS media query currently matches, re-rendering on change + */ +export const useMediaQuery = (query: string) => + useSyncExternalStore( + (onChange) => { + const mql = window.matchMedia(query); + mql.addEventListener("change", onChange); + return () => mql.removeEventListener("change", onChange); + }, + () => window.matchMedia(query).matches, + () => false + ); From 473ab3a70aa8f980ea95badf536a2e2795da0b32 Mon Sep 17 00:00:00 2001 From: Kevin Duong Date: Sat, 3 Oct 2026 19:11:41 -0400 Subject: [PATCH 5/6] fix(app): Fix desktop panel from opening on mobile Mobile and desktop feedback panel both open upon first load. `isDesktop` determines whether the layout is for large screens or small screens. --- .../__tests__/rejectionFeedback.test.tsx | 15 ++-- .../components/review/RejectionFeedback.tsx | 83 +++++++++---------- 2 files changed, 48 insertions(+), 50 deletions(-) diff --git a/app/src/components/__tests__/rejectionFeedback.test.tsx b/app/src/components/__tests__/rejectionFeedback.test.tsx index 66467647..8bd95003 100644 --- a/app/src/components/__tests__/rejectionFeedback.test.tsx +++ b/app/src/components/__tests__/rejectionFeedback.test.tsx @@ -61,13 +61,17 @@ describe("RejectionFeedback", () => { stubScreen(false); render(); - fireEvent.click( - await screen.findByRole("button", { name: "Show panel feedback" }) - ); - - const dialog = screen.getByRole("dialog"); + const dialog = await screen.findByRole("dialog"); expect(within(dialog).getByText("View the closed case")).toBeDefined(); expect(within(dialog).getByText("Needs a worked example.")).toBeDefined(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Close" })); + expect(screen.queryByRole("dialog")).toBeNull(); + + fireEvent.click( + screen.getByRole("button", { name: "Show panel feedback" }) + ); + expect(screen.getByRole("dialog")).toBeDefined(); }); it("reopens the side column instead of a sheet on desktop", async () => { @@ -77,6 +81,7 @@ describe("RejectionFeedback", () => { fireEvent.click( await screen.findByRole("button", { name: "Hide panel feedback" }) ); + expect(screen.queryByRole("dialog")).toBeNull(); fireEvent.click( screen.getByRole("button", { name: "Show panel feedback" }) ); diff --git a/app/src/components/review/RejectionFeedback.tsx b/app/src/components/review/RejectionFeedback.tsx index 5894950d..05eb5a8d 100644 --- a/app/src/components/review/RejectionFeedback.tsx +++ b/app/src/components/review/RejectionFeedback.tsx @@ -1,11 +1,6 @@ import { useEffect, useState } from "react"; import { Link } from "@tanstack/react-router"; -import { - MessageSquareText, - MessageSquareWarning, - PanelRightClose, - PanelRightOpen, -} from "lucide-react"; +import { MessageSquareWarning, PanelRightClose } from "lucide-react"; import type { PanelDecision } from "@/components/review/DecisionList"; import { DecisionList } from "@/components/review/DecisionList"; @@ -71,13 +66,10 @@ const MobileRejectionFeedback = ({ export const RejectionFeedback = ({ draftId }: { draftId: string }) => { const [caseId, setCaseId] = useState(null); const [decisions, setDecisions] = useState>([]); - const [open, setOpen] = useState(true); - // Mobile open/close - const [sheetOpen, setSheetOpen] = useState(false); + const [open, setOpen] = useState(true); const isDesktop = useMediaQuery("(min-width: 60rem)"); - const buttonHidden = isDesktop && open; useEffect(() => { const controller = new AbortController(); @@ -103,57 +95,58 @@ export const RejectionFeedback = ({ draftId }: { draftId: string }) => { -
    - -
    + + + + )} ); }; From 28d48c6f2059b1df39bbf3c27d941345408f5155 Mon Sep 17 00:00:00 2001 From: Kevin Duong Date: Fri, 30 Oct 2026 12:49:58 -0400 Subject: [PATCH 6/6] fix(app): Make button wider only when enough width is available --- app/src/components/contribute/StepperActionHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/components/contribute/StepperActionHeader.tsx b/app/src/components/contribute/StepperActionHeader.tsx index 2adc5c35..e7ad429f 100644 --- a/app/src/components/contribute/StepperActionHeader.tsx +++ b/app/src/components/contribute/StepperActionHeader.tsx @@ -228,7 +228,7 @@ export const StepperActionHeader = ({ onClick={toggleGuidelineModal} > - + View Guidelines