diff --git a/app/(tools)/color-converter/ColorConverterClient.test.tsx b/app/(tools)/color-converter/ColorConverterClient.test.tsx
index 1f6e864..ac45828 100644
--- a/app/(tools)/color-converter/ColorConverterClient.test.tsx
+++ b/app/(tools)/color-converter/ColorConverterClient.test.tsx
@@ -2,11 +2,45 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ColorConverterClient } from "./ColorConverterClient";
import { contrastRatio, parseColor } from "@/lib/color-utils";
+import { useRegisterCommands } from "@/components/shared/CommandPalette";
+
+const { push } = vi.hoisted(() => ({ push: vi.fn() }));
vi.mock("@/components/shared/CommandPalette", () => ({ useRegisterCommands: vi.fn() }));
-vi.mock("@/components/shared/Toast", () => ({ useToast: () => ({ push: vi.fn() }) }));
+vi.mock("@/components/shared/Toast", () => ({ useToast: () => ({ push }) }));
afterEach(cleanup);
+describe("palette clipboard feedback", () => {
+ it.each([
+ ["hex", "#ff0000"],
+ ["rgb", "rgb(255, 0, 0)"],
+ ["hsl", "hsl(0, 100%, 50%)"],
+ ])("reports %s copy results with the correct value", async (format, expected) => {
+ push.mockClear();
+ const writeText = vi.fn().mockRejectedValue(new Error("Clipboard denied"));
+ const clipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard");
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
+ try {
+ render();
+ fireEvent.change(screen.getByLabelText("Color input"), { target: { value: "#ff0000" } });
+ const commands = vi.mocked(useRegisterCommands).mock.calls.at(-1)![1];
+ const command = commands.find(({ id }) => id === `color:copy-${format}`)!;
+ await expect(command.run()).resolves.toBeUndefined();
+ expect(writeText).toHaveBeenCalledOnce();
+ expect(writeText).toHaveBeenCalledWith(expected);
+ expect(push).toHaveBeenCalledExactlyOnceWith("Could not copy — clipboard blocked", "error");
+ push.mockClear();
+ writeText.mockReset().mockResolvedValue(undefined);
+ await command.run();
+ expect(writeText).toHaveBeenCalledExactlyOnceWith(expected);
+ expect(push).toHaveBeenCalledExactlyOnceWith(`Copied ${format.toUpperCase()} to clipboard`, "success");
+ } finally {
+ if (clipboard) Object.defineProperty(navigator, "clipboard", clipboard);
+ else Reflect.deleteProperty(navigator, "clipboard");
+ }
+ });
+});
+
function failingPair() {
render();
const foreground = screen.getByLabelText("Text / Foreground");
diff --git a/app/(tools)/color-converter/ColorConverterClient.tsx b/app/(tools)/color-converter/ColorConverterClient.tsx
index 086db50..d2cca7d 100644
--- a/app/(tools)/color-converter/ColorConverterClient.tsx
+++ b/app/(tools)/color-converter/ColorConverterClient.tsx
@@ -162,8 +162,12 @@ export function ColorConverterClient() {
title: "Copy current color as HEX",
run: async () => {
if (hexValue) {
- await navigator.clipboard.writeText(hexValue);
- push("Copied HEX to clipboard", "success");
+ try {
+ await navigator.clipboard.writeText(hexValue);
+ push("Copied HEX to clipboard", "success");
+ } catch {
+ push("Could not copy — clipboard blocked", "error");
+ }
}
},
},
@@ -173,8 +177,12 @@ export function ColorConverterClient() {
title: "Copy current color as RGB",
run: async () => {
if (rgbValue) {
- await navigator.clipboard.writeText(rgbValue);
- push("Copied RGB to clipboard", "success");
+ try {
+ await navigator.clipboard.writeText(rgbValue);
+ push("Copied RGB to clipboard", "success");
+ } catch {
+ push("Could not copy — clipboard blocked", "error");
+ }
}
},
},
@@ -184,8 +192,12 @@ export function ColorConverterClient() {
title: "Copy current color as HSL",
run: async () => {
if (hslValue) {
- await navigator.clipboard.writeText(hslValue);
- push("Copied HSL to clipboard", "success");
+ try {
+ await navigator.clipboard.writeText(hslValue);
+ push("Copied HSL to clipboard", "success");
+ } catch {
+ push("Could not copy — clipboard blocked", "error");
+ }
}
},
},
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`;
-}
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() {
- {found && (
- <>
-
-
-
- {found.truncated
- ? "More than 1,000 matches, showing the first 1,000"
- : `${found.matches.length.toLocaleString("en-US")} ${found.matches.length === 1 ? "match" : "matches"}`}
-
-
-
- {segments.map((segment, i) =>
- segment.match ? (
- {segment.text}
- ) : (
- segment.text
- )
- )}
-
+
+
+
+ {found?.truncated
+ ? "More than 1,000 matches, showing the first 1,000"
+ : `${matchCount.toLocaleString("en-US")} ${matchCount === 1 ? "match" : "matches"}`}
+
+
+
+
+ {pending ? "Updating matches…" : canNavigate ? `Match ${activeIndex + 1} of ${matchCount}` : "No matches"}
+
+
+
+
+ {segments.map((segment, i) =>
+ segment.matchIndex !== null ? (
+ {segment.text}
+ ) : (
+ segment.text
+ )
+ )}
+
+
+ {found && (
+ <>
{found.matches.length > 0 && (
diff --git a/package-lock.json b/package-lock.json
index ce4b0e8..7c6309b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "toolbelt",
- "version": "0.9.0",
+ "version": "0.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "toolbelt",
- "version": "0.9.0",
+ "version": "0.10.0",
"license": "MIT",
"dependencies": {
"@cfworker/json-schema": "^4.1.1",
diff --git a/package.json b/package.json
index 6197a83..9ee8baf 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "toolbelt",
- "version": "0.9.0",
+ "version": "0.10.0",
"private": true,
"description": "A developer's toolbelt: fast, privacy-first browser tools — JSON formatter, JWT decoder, Base64, URL encoder, invoice generator. By CODERCOPS.",
"license": "MIT",