From a2d7037713cc12a4ab6d8705b475dfefe76b0255 Mon Sep 17 00:00:00 2001 From: Nalla Prakash <81852531+prakashNalla@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:39:37 +0530 Subject: [PATCH 1/4] fix: show JSON and CSV formatter sizes just under 1 MB as MB (#429) --- app/(tools)/csv-formatter/components/StatsBar.tsx | 7 +------ app/(tools)/json-formatter/components/StatsBar.tsx | 13 ++++--------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/app/(tools)/csv-formatter/components/StatsBar.tsx b/app/(tools)/csv-formatter/components/StatsBar.tsx index 0f0bae7..8d67c22 100644 --- a/app/(tools)/csv-formatter/components/StatsBar.tsx +++ b/app/(tools)/csv-formatter/components/StatsBar.tsx @@ -1,5 +1,6 @@ "use client"; +import { formatBytes } from "@/lib/base64-utils"; import { byteLength, delimiterLabel, lineEndingLabel, DELIMITERS, type ParsedCsv } from "@/lib/csv-utils"; export function StatsBar({ parsed, input }: { parsed: ParsedCsv; input: string }) { @@ -43,9 +44,3 @@ function StatCell({ label, value, sub }: { label: string; value: string; sub?: s ); } - -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; -} diff --git a/app/(tools)/json-formatter/components/StatsBar.tsx b/app/(tools)/json-formatter/components/StatsBar.tsx index 299adb8..cf8761a 100644 --- a/app/(tools)/json-formatter/components/StatsBar.tsx +++ b/app/(tools)/json-formatter/components/StatsBar.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { formatBytes } from "@/lib/base64-utils"; import { computeStats, gzipSize } from "@/lib/json-utils"; export function StatsBar({ input, output }: { input: string; output: string }) { @@ -26,9 +27,9 @@ export function StatsBar({ input, output }: { input: string; output: string }) { return (
- - - + + + ); } - -function fmt(n: number): string { - if (n < 1024) return `${n} B`; - if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; - return `${(n / (1024 * 1024)).toFixed(2)} MB`; -} From 1c731dc176617f15b49a33ee00003294d0e903c2 Mon Sep 17 00:00:00 2001 From: kachamsiddarth <86518485+kachamsiddarth@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:46:26 +0530 Subject: [PATCH 2/4] feat(regex-tester): add match navigation (#419) * feat(regex-tester): add match navigation * fix: prevent regex tester auto-scroll and layout shifts during edits --- .../regex-tester/RegexTesterClient.test.tsx | 229 +++++++++++++++++- .../regex-tester/RegexTesterClient.tsx | 115 ++++++--- 2 files changed, 314 insertions(+), 30 deletions(-) diff --git a/app/(tools)/regex-tester/RegexTesterClient.test.tsx b/app/(tools)/regex-tester/RegexTesterClient.test.tsx index 3f11b01..8007233 100644 --- a/app/(tools)/regex-tester/RegexTesterClient.test.tsx +++ b/app/(tools)/regex-tester/RegexTesterClient.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ToastProvider } from "@/components/shared/Toast"; import { RegexTesterClient } from "./RegexTesterClient"; @@ -55,3 +55,230 @@ describe("RegexTesterClient", () => { await waitFor(() => expect(mockWriteText).toHaveBeenCalled()); }); }); + +describe("RegexTesterClient match navigation", () => { + const previous = () => screen.getByRole("button", { name: "Previous match" }); + const next = () => screen.getByRole("button", { name: "Next match" }); + const status = () => screen.getByRole("status", { name: "Match navigation" }); + + function settleInput() { + act(() => vi.advanceTimersByTime(150)); + } + + function changeInput(label: string, value: string) { + fireEvent.change(screen.getByLabelText(label), { target: { value } }); + settleInput(); + } + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("announces the initial position and distinguishes the active highlight", () => { + renderClient(); + expect(status()).toHaveTextContent("Match 1 of 2"); + expect(status()).toHaveAttribute("aria-live", "polite"); + expect(status()).toHaveAttribute("aria-atomic", "true"); + expect(screen.getByLabelText("Match 1")).toHaveAttribute("aria-current", "true"); + expect(screen.getByLabelText("Match 2")).not.toHaveAttribute("aria-current"); + expect(previous()).toBeEnabled(); + expect(next()).toBeEnabled(); + }); + + it("moves forward and backward, wrapping in both directions", () => { + renderClient(); + fireEvent.click(next()); + expect(status()).toHaveTextContent("Match 2 of 2"); + expect(screen.getByLabelText("Match 2")).toHaveAttribute("aria-current", "true"); + expect(screen.getByLabelText("Match 1")).not.toHaveAttribute("aria-current"); + fireEvent.click(next()); + expect(status()).toHaveTextContent("Match 1 of 2"); + fireEvent.click(previous()); + expect(status()).toHaveTextContent("Match 2 of 2"); + fireEvent.click(previous()); + expect(status()).toHaveTextContent("Match 1 of 2"); + expect(screen.getByLabelText("Match 1")).toHaveAttribute("aria-current", "true"); + }); + + it("keeps a single match selected in both directions", () => { + const scroll = vi.spyOn(HTMLElement.prototype, "scrollIntoView").mockImplementation(() => {}); + renderClient(); + changeInput("Pattern", "^Released"); + expect(scroll).not.toHaveBeenCalled(); + fireEvent.click(next()); + fireEvent.click(previous()); + expect(status()).toHaveTextContent("Match 1 of 1"); + expect(screen.getByLabelText("Match 1")).toHaveAttribute("aria-current", "true"); + expect(scroll).toHaveBeenCalledTimes(2); + expect(scroll.mock.contexts.at(-1)).toBe(screen.getByLabelText("Match 1")); + }); + + it.each(["no matches", "empty pattern", "overlong text"])("disables navigation for %s", (scenario) => { + renderClient(); + if (scenario === "no matches") changeInput("Pattern", "not-present"); + else if (scenario === "empty pattern") changeInput("Pattern", ""); + else changeInput("Test text", "a".repeat(100_001)); + expect(previous()).toBeDisabled(); + expect(next()).toBeDisabled(); + expect(status()).toHaveTextContent("No matches"); + expect(document.querySelector('[aria-current="true"]')).toBeNull(); + }); + + it.each(["Pattern", "Test text", "flags"])("resets selection when %s changes", (field) => { + renderClient(); + fireEvent.click(next()); + expect(status()).toHaveTextContent("Match 2 of 2"); + if (field === "flags") fireEvent.click(screen.getByRole("button", { name: "Ignore case" })); + else fireEvent.change(screen.getByLabelText(field), { + target: { value: field === "Pattern" ? "\\d{4}" : "2027-01-01 and 2028-02-02" }, + }); + expect(status()).toHaveTextContent("Updating matches"); + expect(next()).toBeDisabled(); + expect(previous()).toBeDisabled(); + settleInput(); + expect(status()).toHaveTextContent("Match 1 of 2"); + expect(screen.getByLabelText("Match 1")).toHaveAttribute("aria-current", "true"); + }); + + it("clears invalid results and restores navigation after correcting the pattern", () => { + renderClient(); + fireEvent.click(next()); + changeInput("Pattern", "("); + expect(screen.getByRole("alert")).toHaveTextContent("Invalid pattern"); + expect(next()).toBeDisabled(); + expect(previous()).toBeDisabled(); + expect(status()).toHaveTextContent("No matches"); + expect(document.querySelector('[aria-current="true"]')).toBeNull(); + changeInput("Pattern", "\\d{4}"); + expect(status()).toHaveTextContent("Match 1 of 2"); + expect(next()).toBeEnabled(); + expect(previous()).toBeEnabled(); + }); + + it("keeps the index valid through batched edits and shrinking results", () => { + renderClient(); + fireEvent.click(next()); + fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: "(" } }); + fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: "a" } }); + fireEvent.change(screen.getByLabelText("Test text"), { target: { value: "aaa" } }); + settleInput(); + fireEvent.click(previous()); + expect(status()).toHaveTextContent("Match 3 of 3"); + changeInput("Test text", "a"); + expect(status()).toHaveTextContent("Match 1 of 1"); + changeInput("Test text", "b"); + expect(status()).toHaveTextContent("No matches"); + changeInput("Test text", "aa"); + expect(status()).toHaveTextContent("Match 1 of 2"); + }); + + it("navigates visible markers for zero-length matches, including the end of input", () => { + renderClient(); + fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: "(?:)" } }); + fireEvent.change(screen.getByLabelText("Test text"), { target: { value: "ab" } }); + settleInput(); + expect(status()).toHaveTextContent("Match 1 of 3"); + fireEvent.click(next()); + expect(status()).toHaveTextContent("Match 2 of 3"); + expect(screen.getByLabelText("Match 2 (empty)")).toHaveAttribute("aria-current", "true"); + fireEvent.click(previous()); + fireEvent.click(previous()); + expect(status()).toHaveTextContent("Match 3 of 3"); + expect(screen.getByLabelText("Match 3 (empty)")).toHaveAttribute("aria-current", "true"); + fireEvent.click(next()); + expect(status()).toHaveTextContent("Match 1 of 3"); + expect(screen.getByLabelText("Highlighted matches")).toHaveTextContent("ab"); + }); + + it("navigates only the 1,000 displayed matches when results are truncated", () => { + renderClient(); + const position = status(); + const previousButton = previous(); + const nextButton = next(); + fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: "a" } }); + fireEvent.change(screen.getByLabelText("Test text"), { target: { value: "a".repeat(1001) } }); + settleInput(); + expect(position).toHaveTextContent("Match 1 of 1000"); + expect(screen.getByRole("heading", { name: "More than 1,000 matches, showing the first 1,000" })).toBeInTheDocument(); + fireEvent.click(previousButton); + expect(position).toHaveTextContent("Match 1000 of 1000"); + expect(screen.getByLabelText("Match 1000")).toHaveAttribute("aria-current", "true"); + expect(screen.queryByLabelText("Match 1001")).not.toBeInTheDocument(); + fireEvent.click(nextButton); + expect(position).toHaveTextContent("Match 1 of 1000"); + }); + + it("scrolls the active highlight without stealing focus or reacting to replacement edits", () => { + const scroll = vi.spyOn(HTMLElement.prototype, "scrollIntoView").mockImplementation(() => {}); + renderClient(); + scroll.mockClear(); + next().focus(); + fireEvent.click(next()); + expect(next()).toHaveFocus(); + expect(scroll).toHaveBeenLastCalledWith({ block: "nearest", inline: "nearest" }); + expect(scroll.mock.contexts.at(-1)).toBe(screen.getByLabelText("Match 2")); + fireEvent.click(next()); + expect(scroll.mock.contexts.at(-1)).toBe(screen.getByLabelText("Match 1")); + previous().focus(); + fireEvent.click(previous()); + expect(previous()).toHaveFocus(); + expect(scroll.mock.contexts.at(-1)).toBe(screen.getByLabelText("Match 2")); + scroll.mockClear(); + changeInput("Replace with", "updated"); + expect(status()).toHaveTextContent("Match 2 of 2"); + expect(scroll).not.toHaveBeenCalled(); + }); + + it.each(["Pattern", "Test text", "flags"])("scrolls only on navigation, not on mount or a %s edit", (field) => { + const scroll = vi.spyOn(HTMLElement.prototype, "scrollIntoView").mockImplementation(() => {}); + renderClient(); + settleInput(); + expect(scroll).not.toHaveBeenCalled(); + + fireEvent.click(next()); + expect(scroll).toHaveBeenCalledTimes(1); + expect(scroll.mock.contexts.at(-1)).toBe(screen.getByLabelText("Match 2")); + if (field === "flags") fireEvent.click(screen.getByRole("button", { name: "Ignore case" })); + else fireEvent.change(screen.getByLabelText(field), { + target: { value: field === "Pattern" ? "\\d{4}" : "2027-01-01 and 2028-02-02" }, + }); + expect(scroll).toHaveBeenCalledTimes(1); + settleInput(); + expect(status()).toHaveTextContent("Match 1 of 2"); + expect(scroll).toHaveBeenCalledTimes(1); + + fireEvent.click(previous()); + expect(scroll).toHaveBeenCalledTimes(2); + expect(scroll.mock.contexts.at(-1)).toBe(screen.getByLabelText("Match 2")); + }); + + it.each(["Pattern", "Test text"])("keeps match details and replacement mounted while %s is pending", (field) => { + renderClient(); + fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: "a" } }); + changeInput("Test text", "a a a"); + const details = screen.getByRole("table"); + const replacement = screen.getByLabelText("Replace with"); + + fireEvent.change(screen.getByLabelText(field), { + target: { value: field === "Pattern" ? "a+" : "a a a a" }, + }); + expect(status()).toHaveTextContent("Updating matches"); + expect(next()).toBeDisabled(); + expect(previous()).toBeDisabled(); + expect(document.querySelector('[aria-current="true"]')).toBeNull(); + expect(screen.getByRole("table")).toBe(details); + expect(screen.getByLabelText("Replace with")).toBe(replacement); + expect(screen.getByRole("heading", { name: "3 matches" })).toBeInTheDocument(); + + settleInput(); + expect(screen.getByRole("table")).toBe(details); + expect(screen.getByLabelText("Replace with")).toBe(replacement); + expect(status()).toHaveTextContent(field === "Pattern" ? "Match 1 of 3" : "Match 1 of 4"); + }); +}); diff --git a/app/(tools)/regex-tester/RegexTesterClient.tsx b/app/(tools)/regex-tester/RegexTesterClient.tsx index 629df04..934e2e3 100644 --- a/app/(tools)/regex-tester/RegexTesterClient.tsx +++ b/app/(tools)/regex-tester/RegexTesterClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { CopyButton } from "@/components/shared/CopyButton"; import { compileRegex, findMatches, replacePreview } from "@/lib/regex-utils"; @@ -27,6 +27,9 @@ export function RegexTesterClient() { const [text, setText] = useState("Released on 2026-10-01, patched on 2026-10-14."); const [replacement, setReplacement] = useState("$3/$2/$"); const [input, setInput] = useState({ pattern, flags, text, replacement }); + const [activeMatchIndex, setActiveMatchIndex] = useState(0); + const [scrollRequest, setScrollRequest] = useState(0); + const activeMatchRef = useRef(null); useEffect(() => { const timer = window.setTimeout(() => setInput({ pattern, flags, text, replacement }), DEBOUNCE_MS); @@ -36,30 +39,45 @@ export function RegexTesterClient() { const tooLong = input.text.length > MAX_TEXT_LENGTH; const compiled = useMemo(() => compileRegex(input.pattern, input.flags), [input.pattern, input.flags]); const regex = compiled.ok && input.pattern !== "" && !tooLong ? compiled.regex : null; - const found = useMemo(() => (regex ? findMatches(regex, input.text) : null), [regex, input.text]); + const results = useMemo(() => (regex ? findMatches(regex, input.text) : null), [regex, input.text]); + const pending = pattern !== input.pattern || flags !== input.flags || text !== input.text; + const found = results; + const matchCount = found?.matches.length ?? 0; + const canNavigate = !pending && matchCount > 0; + const activeIndex = canNavigate ? Math.min(activeMatchIndex, matchCount - 1) : -1; + + useEffect(() => { + if (scrollRequest > 0) activeMatchRef.current?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }, [scrollRequest]); const replaced = useMemo( () => (regex ? replacePreview(regex, input.text, input.replacement) : ""), [regex, input.text, input.replacement] ); - const segments: { text: string; match: boolean }[] = []; + const segments: { text: string; matchIndex: number | null }[] = []; if (found) { let last = 0; - for (const match of found.matches) { - if (match.end === match.index) continue; - segments.push({ text: input.text.slice(last, match.index), match: false }); - segments.push({ text: match.text, match: true }); + for (const [matchIndex, match] of found.matches.entries()) { + segments.push({ text: input.text.slice(last, match.index), matchIndex: null }); + segments.push({ text: match.text, matchIndex }); last = match.end; } - segments.push({ text: input.text.slice(last), match: false }); + segments.push({ text: input.text.slice(last), matchIndex: null }); } function toggleFlag(flag: string) { + setActiveMatchIndex(0); setFlags((current) => FLAGS.map((f) => f.flag).filter((f) => (f === flag ? !current.includes(f) : current.includes(f))).join("") ); } + function goToMatch(step: number) { + if (!canNavigate) return; + setActiveMatchIndex((activeIndex + step + matchCount) % matchCount); + setScrollRequest((request) => request + 1); + } + return (
@@ -71,7 +89,10 @@ export function RegexTesterClient() { id="regex-pattern" type="text" value={pattern} - onChange={(event) => setPattern(event.target.value)} + onChange={(event) => { + setPattern(event.target.value); + setActiveMatchIndex(0); + }} placeholder="\d+" autoComplete="off" autoCapitalize="off" @@ -110,7 +131,10 @@ export function RegexTesterClient() {