From 5db004a3325b189368808ce99064b159a701e257 Mon Sep 17 00:00:00 2001 From: Obvious Date: Sun, 18 Oct 2026 19:15:33 +0000 Subject: [PATCH 1/3] feat(options): collapse bar into Parse settings drop-up below fit threshold Replace the wrapping options footer and the small-screen accordion with one behavior: below the measured fit threshold the bar renders a single Parse settings line (trigger, live summary, large-file notice) and the full control set lives in a drop-up panel above it. Mirrors the FeedbackVote popout pattern for dismissal and focus return. Co-authored-by: David Boskovic --- app/src/App.test.tsx | 29 ++-- app/src/components/OptionsRow.test.tsx | 209 +++++++++++++++++++++---- app/src/components/OptionsRow.tsx | 188 ++++++++++++++++------ app/src/index.css | 9 ++ 4 files changed, 346 insertions(+), 89 deletions(-) diff --git a/app/src/App.test.tsx b/app/src/App.test.tsx index 0fb8eff..e288df5 100644 --- a/app/src/App.test.tsx +++ b/app/src/App.test.tsx @@ -27,6 +27,7 @@ import { copyText } from "@/lib/clipboard"; import { convertText } from "@/lib/convert"; import { downloadText } from "@/lib/download"; import { ErrorBoundary } from "./components/ErrorBoundary"; +import { setMediaQueryMatches } from "./test/setup"; async function flip() { const user = userEvent.setup(); @@ -1204,8 +1205,11 @@ describe("CSV upload responsiveness & blank-page resilience (AC-7 / art_cPIu3u6a }); describe("mobile & small-screen layout polish (AC-8 / D7)", () => { - it("unclamps the converter shell on { + it("unclamps the converter shell on { const user = userEvent.setup(); + // Below the fit threshold (see index.css --breakpoint-fit): collapsed + // options bar + stacked panes. + setMediaQueryMatches(false); render(); // Converter shell uses min-h-svh on mobile and h-svh on md+ @@ -1233,20 +1237,25 @@ describe("mobile & small-screen layout polish (AC-8 / D7)", () => { expect(span?.className).toContain("xl:inline"); } - // Mobile options disclosure toggles aria-expanded and updates summary when direction flips - const mobileToggle = screen.getByTestId("options-mobile-toggle"); - expect(mobileToggle).toHaveAttribute("aria-expanded", "false"); - expect(screen.getByTestId("options-mobile-summary")).toHaveTextContent( - "Auto · Parse numbers · Parse JSON" - ); + // The options bar collapses to the Parse settings drop-up trigger below + // the fit threshold; the summary names the active options and flips with + // the direction. Opening the menu then flipping direction also exercises + // the outside-click dismissal (the seam switch is outside the bar). + const settingsToggle = screen.getByTestId("options-settings-toggle"); + expect(settingsToggle).toHaveAttribute("aria-expanded", "false"); + expect( + screen.getByTestId("options-settings-summary") + ).toHaveTextContent("Auto · Parse numbers · Parse JSON"); - await user.click(mobileToggle); - expect(mobileToggle).toHaveAttribute("aria-expanded", "true"); + await user.click(settingsToggle); + expect(settingsToggle).toHaveAttribute("aria-expanded", "true"); await user.click(screen.getByTestId("divider-switch")); - expect(screen.getByTestId("options-mobile-summary")).toHaveTextContent( + expect(screen.getByTestId("options-settings-summary")).toHaveTextContent( "Auto" ); + expect(settingsToggle).toHaveAttribute("aria-expanded", "false"); + expect(settingsToggle).toHaveFocus(); }); }); diff --git a/app/src/components/OptionsRow.test.tsx b/app/src/components/OptionsRow.test.tsx index 629d8e9..c7473bd 100644 --- a/app/src/components/OptionsRow.test.tsx +++ b/app/src/components/OptionsRow.test.tsx @@ -1,7 +1,9 @@ -import { fireEvent, render, screen, within } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { useState } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_OPTIONS } from "@/lib/convert"; +import { setMediaQueryMatches } from "@/test/setup"; import { OptionsRow } from "./OptionsRow"; describe("OptionsRow option hints", () => { @@ -223,8 +225,27 @@ describe("empty/NULL toggles (spec B1 — issues #65 #100 #46 #6)", () => { screen.queryByRole("switch", { name: "NULL as null" }) ).not.toBeInTheDocument(); }); +}); + +describe("parse settings drop-up (spec: parse settings menu, art_kW2qUlGL)", () => { + beforeEach(() => { + // Below the fit threshold (xl): the collapsed presentation. + setMediaQueryMatches(false); + }); + + /** Controlled harness so in-menu toggles flow through App-like state. */ + function SummaryHarness() { + const [options, setOptions] = useState(DEFAULT_OPTIONS); + return ( + setOptions((current) => ({ ...current, ...patch }))} + /> + ); + } - it("collapses option controls behind options-mobile-toggle on { + it("collapses to a one-line Parse settings bar with the full control set mounted in the DOM (SEO initial-DOM rule)", () => { render( { /> ); - const toggle = screen.getByTestId("options-mobile-toggle"); - const summary = screen.getByTestId("options-mobile-summary"); - const controls = screen.getByTestId("options-controls"); + const bar = screen.getByTestId("options-row"); + const toggle = screen.getByTestId("options-settings-toggle"); + expect(toggle).toHaveTextContent("Parse settings"); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(toggle).toHaveAttribute("aria-haspopup", "true"); + expect(screen.getByTestId("options-settings-summary")).toHaveTextContent( + "Auto · Parse numbers · Parse JSON" + ); + + // One line at rest: the bar itself never stacks controls — no flex-wrap. + expect(bar.className).not.toContain("flex-wrap"); + + // Closed menu still carries the full set (and hints) in the DOM. + const menu = screen.getByTestId("options-settings-menu"); + expect(menu).toHaveAttribute("hidden"); + expect(within(menu).getByTestId("opt-separator")).toBeInTheDocument(); + // Role queries default to the accessibility tree — the closed menu is + // hidden, so include it explicitly. + expect( + within(menu).getAllByRole("switch", { hidden: true }) + ).toHaveLength(7); + expect(menu.querySelectorAll("[data-hint]").length).toBeGreaterThanOrEqual( + 8 + ); + }); + + it("opens via the trigger and closes via trigger re-click, Escape, and outside click — returning focus to the trigger each time", async () => { + const user = userEvent.setup(); + render( + <> + + {}} + /> + + ); + const toggle = screen.getByTestId("options-settings-toggle"); + const menu = () => screen.getByTestId("options-settings-menu"); + + await user.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + expect(menu()).not.toHaveAttribute("hidden"); + + // Trigger re-click closes. + await user.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(menu()).toHaveAttribute("hidden"); + expect(toggle).toHaveFocus(); + + // Escape closes and returns focus. + await user.click(toggle); + await user.keyboard("{Escape}"); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(toggle).toHaveFocus(); + // Outside click closes and returns focus (let user-event settle the + // outside button's focus first — the close refocuses the trigger). + await user.click(toggle); + await user.click(screen.getByTestId("outside")); expect(toggle).toHaveAttribute("aria-expanded", "false"); - expect(summary).toHaveTextContent("Auto · Parse numbers · Parse JSON"); - expect(controls.className).toContain("max-md:hidden"); - expect(controls.className).toContain("md:flex"); + expect(toggle).toHaveFocus(); + }); + + it("updates the collapsed summary immediately when a toggle inside the menu changes", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("options-settings-toggle")); + + await user.click(screen.getByRole("switch", { name: "Parse numbers" })); + expect(screen.getByTestId("options-settings-summary")).toHaveTextContent( + "Auto · Parse JSON" + ); + // A control interaction never dismisses the menu. + expect(screen.getByTestId("options-settings-menu")).not.toHaveAttribute( + "hidden" + ); + }); + + it("keeps the large-file notice on the collapsed row, never inside the menu", async () => { + const user = userEvent.setup(); + render( + {}} + notice="Large file — converting on pause" + /> + ); + const menu = screen.getByTestId("options-settings-menu"); + const notice = screen.getByTestId("options-notice"); + expect(notice).toHaveTextContent("Large file — converting on pause"); + expect(menu.contains(notice)).toBe(false); + + await user.click(screen.getByTestId("options-settings-toggle")); + expect(screen.getByTestId("options-notice")).toBe(notice); + expect(menu.contains(notice)).toBe(false); + }); - // Even when collapsed on mobile, every option control and data-hint node remains in the DOM for SEO prerender. - expect(screen.getByTestId("opt-separator")).toBeInTheDocument(); + it("keeps the direction-conditional contents and the encoding select wiring inside the menu", () => { + const wired = render( + {}} + uploadEncoding="utf-8" + onUploadEncodingChange={() => {}} + /> + ); + const menu = wired.getByTestId("options-settings-menu"); expect( - screen.getByRole("switch", { name: "Parse numbers" }) + within(menu).getByRole("switch", { + name: "Flatten nested arrays", + hidden: true, + }) ).toBeInTheDocument(); expect( - document.querySelectorAll("[data-hint]").length - ).toBeGreaterThanOrEqual(8); + within(menu).queryByRole("switch", { + name: "Parse numbers", + hidden: true, + }) + ).not.toBeInTheDocument(); + expect(within(menu).getByTestId("opt-encoding")).toBeInTheDocument(); + wired.unmount(); - fireEvent.click(toggle); - expect(toggle).toHaveAttribute("aria-expanded", "true"); - expect(controls.className).not.toContain("max-md:hidden"); - expect(controls.className).toContain("flex"); + // Unwired encoding props → no select, same as the inline row. + const unwired = render( + {}} + /> + ); + expect( + unwired.queryByTestId("opt-encoding") + ).not.toBeInTheDocument(); + unwired.unmount(); + }); - fireEvent.click(toggle); - expect(toggle).toHaveAttribute("aria-expanded", "false"); - expect(controls.className).toContain("max-md:hidden"); + it("renders the inline unwrapped row at the fit threshold and above, with no collapse trigger", () => { + setMediaQueryMatches(true); + render( + {}} + /> + ); + expect( + screen.queryByTestId("options-settings-toggle") + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("options-settings-summary") + ).not.toBeInTheDocument(); + const menu = screen.getByTestId("options-settings-menu"); + expect(menu).not.toHaveAttribute("hidden"); + expect(within(menu).getAllByRole("switch")).toHaveLength(7); }); +}); +describe("legacy option-bar layout", () => { it("hides vertical Divider rules until xl:block and only renders the trailing flex-1 spacer when notice is present (AC-9 / D8)", () => { const { rerender } = render( { /> ); - const controls = screen.getByTestId("options-controls"); - const dividers = Array.from(controls.querySelectorAll("div.w-px")); + const menu = screen.getByTestId("options-settings-menu"); + const dividers = Array.from(menu.querySelectorAll("div.w-px")); expect(dividers.length).toBeGreaterThanOrEqual(2); for (const divider of dividers) { expect(divider.className).toContain("hidden"); - expect(divider.className).toContain("xl:block"); + expect(divider.className).toContain("fit:block"); expect(divider.className).not.toContain("md:block"); } - // Without notice, no trailing flex-1 spacer is rendered inside options-controls - expect(controls.querySelector("div.flex-1")).toBeNull(); + // Without notice, no trailing flex-1 spacer renders inside the inline row + expect(menu.querySelector("div.flex-1")).toBeNull(); // When notice is provided, the spacer and notice render rerender( @@ -293,7 +448,7 @@ describe("empty/NULL toggles (spec B1 — issues #65 #100 #46 #6)", () => { notice="Large file — converting on pause" /> ); - expect(controls.querySelector("div.flex-1")).not.toBeNull(); + expect(menu.querySelector("div.flex-1")).not.toBeNull(); expect(screen.getByTestId("options-notice")).toHaveTextContent( "Large file — converting on pause" ); diff --git a/app/src/components/OptionsRow.tsx b/app/src/components/OptionsRow.tsx index 698def4..757c0eb 100644 --- a/app/src/components/OptionsRow.tsx +++ b/app/src/components/OptionsRow.tsx @@ -1,7 +1,8 @@ import type { ConverterOptions, Direction } from "@/lib/convert"; import { UPLOAD_ENCODINGS, type UploadEncoding } from "@/lib/files"; import { ChevronDown } from "lucide-react"; -import { useId, useState } from "react"; +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { useMediaQuery } from "@/hooks/useMediaQuery"; import { cn } from "@/lib/utils"; type OptionsRowProps = { @@ -24,10 +25,12 @@ const SEPARATOR_OPTIONS: Array<{ value: ConverterOptions["separator"]; label: st ]; /** - * Group divider between the footer's option clusters (v0 port). + * Group divider between the footer's option clusters (v0 port). Hidden below + * the fit threshold: the drop-up panel wraps, and a rule dangling at a line + * break reads as a stray mark. `fit` is the measured threshold in index.css. */ function Divider() { - return