diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 7b9a7a8c..06b3e987 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -770,5 +770,41 @@ "signed": "Transaction signed successfully", "submitted": "Transaction submitted successfully" } + }, + "transactionFilters": { + "title": "Filters", + "landmarkLabel": "Transaction filters", + "closeFilters": "Close filters", + "ariaLabel": "Clear search", + "applyingToResults": "Applying to results…", + "clearAll": "Clear all filters", + "clearAllAriaLabel": "Clear all {count, plural, one {1 active filter} other {# active filters}}", + "clearing": "Clearing…", + "search": { + "label": "Search", + "placeholder": "Search by ID or description", + "syncLabel": "Searching…" + }, + "status": { + "label": "Status", + "all": "All Statuses", + "pending": "Pending", + "confirmed": "Confirmed", + "failed": "Failed", + "refunded": "Refunded", + "syncLabel": "Updating status…" + }, + "asset": { + "label": "Asset filter", + "all": "All", + "syncLabel": "Updating asset…" + }, + "dateRange": { + "label": "Date range", + "from": "From", + "to": "To", + "fromSyncLabel": "Updating start date…", + "toSyncLabel": "Updating end date…" + } } -} \ No newline at end of file +} diff --git a/frontend/messages/es.json b/frontend/messages/es.json index c2b707e9..ccb2e34c 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -748,5 +748,41 @@ "signed": "Transacción firmada con éxito", "submitted": "Transacción enviada con éxito" } + }, + "transactionFilters": { + "title": "Filtros", + "landmarkLabel": "Filtros de transacciones", + "closeFilters": "Cerrar filtros", + "ariaLabel": "Borrar búsqueda", + "applyingToResults": "Aplicando a los resultados…", + "clearAll": "Borrar todos los filtros", + "clearAllAriaLabel": "Borrar {count, plural, one {1 filtro activo} other {# filtros activos}}", + "clearing": "Borrando…", + "search": { + "label": "Búsqueda", + "placeholder": "Buscar por ID o descripción", + "syncLabel": "Buscando…" + }, + "status": { + "label": "Estado", + "all": "Todos los estados", + "pending": "Pendiente", + "confirmed": "Confirmado", + "failed": "Fallido", + "refunded": "Reembolsado", + "syncLabel": "Actualizando estado…" + }, + "asset": { + "label": "Filtro de activo", + "all": "Todos", + "syncLabel": "Actualizando activo…" + }, + "dateRange": { + "label": "Rango de fechas", + "from": "Desde", + "to": "Hasta", + "fromSyncLabel": "Actualizando fecha de inicio…", + "toSyncLabel": "Actualizando fecha de fin…" + } } -} \ No newline at end of file +} diff --git a/frontend/messages/pt.json b/frontend/messages/pt.json index 58a764c9..bebc4db0 100644 --- a/frontend/messages/pt.json +++ b/frontend/messages/pt.json @@ -748,5 +748,41 @@ "signed": "Transação assinada com sucesso", "submitted": "Transação enviada com sucesso" } + }, + "transactionFilters": { + "title": "Filtros", + "landmarkLabel": "Filtros de transações", + "closeFilters": "Fechar filtros", + "ariaLabel": "Limpar busca", + "applyingToResults": "Aplicando aos resultados…", + "clearAll": "Limpar todos os filtros", + "clearAllAriaLabel": "Limpar {count, plural, one {1 filtro ativo} other {# filtros ativos}}", + "clearing": "Limpando…", + "search": { + "label": "Busca", + "placeholder": "Buscar por ID ou descrição", + "syncLabel": "Buscando…" + }, + "status": { + "label": "Status", + "all": "Todos os status", + "pending": "Pendente", + "confirmed": "Confirmado", + "failed": "Falhou", + "refunded": "Reembolsado", + "syncLabel": "Atualizando status…" + }, + "asset": { + "label": "Filtro de ativo", + "all": "Todos", + "syncLabel": "Atualizando ativo…" + }, + "dateRange": { + "label": "Intervalo de datas", + "from": "De", + "to": "Até", + "fromSyncLabel": "Atualizando data inicial…", + "toSyncLabel": "Atualizando data final…" + } } -} \ No newline at end of file +} diff --git a/frontend/src/components/TransactionFilterSidebar.test.tsx b/frontend/src/components/TransactionFilterSidebar.test.tsx index 46f0dca8..770bd5b6 100644 --- a/frontend/src/components/TransactionFilterSidebar.test.tsx +++ b/frontend/src/components/TransactionFilterSidebar.test.tsx @@ -13,10 +13,11 @@ * ✅ Mobile drawer — open/close via button and backdrop click * ✅ Edge cases — disabled Clear All, hidden clear-search, "Clearing…" * - * Component patches required (TransactionFilterSidebar.tsx): - * 1. Add aria-label="Clear search" to the clear-search motion.button - * 2. Add aria-hidden="true" to the SyncSpinner wrapper span inside asset buttons - * so the spinner text is excluded from the button's accessible name + * The component reads its copy from the `transactionFilters` i18n namespace + * via `useTranslations`, so every render in this file must happen inside a + * `NextIntlClientProvider` — see `renderSidebar` below. Without it, + * `useTranslations` throws (no provider context found) and every test in + * this file fails, regardless of what it's actually asserting. */ import React from "react"; @@ -24,8 +25,19 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom/vitest"; +import { NextIntlClientProvider } from "next-intl"; import TransactionFilterSidebar from "./TransactionFilterSidebar"; +import enMessages from "../../messages/en.json"; + +/** Renders with the real `transactionFilters` messages, exactly as the app does via NextIntlClientProvider in layout.tsx. */ +function renderSidebar(props: React.ComponentProps) { + return render( + + + , + ); +} // ─── framer-motion mock ─────────────────────────────────────────────────────── // jsdom has no layout engine so framer-motion's measurement APIs fail. @@ -109,17 +121,17 @@ describe("TransactionFilterSidebar", () => { describe("1 · Rendering", () => { describe("desktop panel", () => { it("always renders the sticky desktop panel", () => { - const { container } = render(); + const { container } = renderSidebar(buildProps()); expect(getDesktopPanel(container)).toBeInTheDocument(); }); it("renders the 'Filters' heading", () => { - const { container } = render(); + const { container } = renderSidebar(buildProps()); expect(within(getDesktopPanel(container)).getByText("Filters")).toBeInTheDocument(); }); it("renders Search input, Status select, Asset group, From and To date inputs", () => { - const { container } = render(); + const { container } = renderSidebar(buildProps()); const panel = getDesktopPanel(container); expect(getInput(panel, /Search/i)).toBeInTheDocument(); expect(getSelect(panel, /Status/i)).toBeInTheDocument(); @@ -129,7 +141,7 @@ describe("TransactionFilterSidebar", () => { }); it("renders all 5 status options with correct display labels", () => { - const { container } = render(); + const { container } = renderSidebar(buildProps()); const select = getSelect(getDesktopPanel(container), /Status/i) as HTMLSelectElement; expect(Array.from(select.options).map((o) => o.text)).toEqual([ "All Statuses", "Pending", "Confirmed", "Failed", "Refunded", @@ -137,7 +149,7 @@ describe("TransactionFilterSidebar", () => { }); it("renders All / XLM / USDC asset buttons", () => { - const { container } = render(); + const { container } = renderSidebar(buildProps()); const group = within(getDesktopPanel(container)).getByRole("group", { name: /Asset filter/i }); expect(within(group).getByRole("button", { name: /^All$/i })).toBeInTheDocument(); expect(within(group).getByRole("button", { name: /^XLM$/i })).toBeInTheDocument(); @@ -145,7 +157,7 @@ describe("TransactionFilterSidebar", () => { }); it("renders the 'Clear All Filters' footer button", () => { - const { container } = render(); + const { container } = renderSidebar(buildProps()); expect( within(getDesktopPanel(container)).getByRole("button", { name: /Clear All Filters/i }), ).toBeInTheDocument(); @@ -154,22 +166,22 @@ describe("TransactionFilterSidebar", () => { describe("mobile drawer", () => { it("does NOT render the dialog when isOpen=false", () => { - render(); + renderSidebar(buildProps({ isOpen: false })); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); it("renders the dialog when isOpen=true", () => { - render(); - expect(screen.getByRole("dialog", { name: /Filter sidebar/i })).toBeInTheDocument(); + renderSidebar(buildProps({ isOpen: true })); + expect(screen.getByRole("dialog", { name: /Transaction filters/i })).toBeInTheDocument(); }); it("dialog has aria-modal='true'", () => { - render(); + renderSidebar(buildProps({ isOpen: true })); expect(screen.getByRole("dialog")).toHaveAttribute("aria-modal", "true"); }); it("dialog contains all the same filter fields as the desktop panel", () => { - render(); + renderSidebar(buildProps({ isOpen: true })); const dialog = screen.getByRole("dialog"); expect(within(dialog).getByLabelText(/Search/i, { selector: "input" })).toBeInTheDocument(); expect(within(dialog).getByLabelText(/Status/i, { selector: "select" })).toBeInTheDocument(); @@ -181,57 +193,33 @@ describe("TransactionFilterSidebar", () => { describe("controlled values", () => { it("reflects search value in the search input", () => { - const { container } = render( - , - ); + const { container } = renderSidebar(buildProps({ filters: { ...DEFAULT_FILTERS, search: "abc-123" } })); expect(getInput(getDesktopPanel(container), /Search/i)).toHaveValue("abc-123"); }); it("reflects status value in the status select", () => { - const { container } = render( - , - ); + const { container } = renderSidebar(buildProps({ filters: { ...DEFAULT_FILTERS, status: "failed" } })); expect(getSelect(getDesktopPanel(container), /Status/i)).toHaveValue("failed"); }); it("reflects dateFrom value", () => { - const { container } = render( - , - ); + const { container } = renderSidebar(buildProps({ filters: { ...DEFAULT_FILTERS, dateFrom: "2024-03-01" } })); expect(getInput(getDesktopPanel(container), /From/i)).toHaveValue("2024-03-01"); }); it("reflects dateTo value", () => { - const { container } = render( - , - ); + const { container } = renderSidebar(buildProps({ filters: { ...DEFAULT_FILTERS, dateTo: "2024-12-31" } })); expect(getInput(getDesktopPanel(container), /To/i)).toHaveValue("2024-12-31"); }); it("marks active asset button with aria-pressed='true'", () => { - const { container } = render( - , - ); + const { container } = renderSidebar(buildProps({ filters: { ...DEFAULT_FILTERS, asset: "USDC" } })); const group = within(getDesktopPanel(container)).getByRole("group", { name: /Asset filter/i }); expect(within(group).getByRole("button", { name: /^USDC$/i })).toHaveAttribute("aria-pressed", "true"); }); it("marks inactive asset buttons with aria-pressed='false'", () => { - const { container } = render( - , - ); + const { container } = renderSidebar(buildProps({ filters: { ...DEFAULT_FILTERS, asset: "USDC" } })); const group = within(getDesktopPanel(container)).getByRole("group", { name: /Asset filter/i }); expect(within(group).getByRole("button", { name: /^XLM$/i })).toHaveAttribute("aria-pressed", "false"); expect(within(group).getByRole("button", { name: /^All$/i })).toHaveAttribute("aria-pressed", "false"); @@ -245,28 +233,24 @@ describe("TransactionFilterSidebar", () => { describe("search input", () => { it("calls onFilterChange('search', value) on change", async () => { const props = buildProps(); - const { container } = render(); + const { container } = renderSidebar(props); await userEvent.type(getInput(getDesktopPanel(container), /Search/i), "x"); expect(props.onFilterChange).toHaveBeenCalledWith("search", "x"); }); it("does NOT render clear-search button when search is empty", () => { - const { container } = render(); + const { container } = renderSidebar(buildProps()); expect(within(getDesktopPanel(container)).queryByLabelText(/Clear search/i)).not.toBeInTheDocument(); }); it("renders clear-search button when search has a value", () => { - const { container } = render( - , - ); + const { container } = renderSidebar(buildProps({ filters: { ...DEFAULT_FILTERS, search: "query" } })); expect(within(getDesktopPanel(container)).getByLabelText(/Clear search/i)).toBeInTheDocument(); }); it("calls onClearFilter('search') when clear-search button is clicked", () => { const props = buildProps({ filters: { ...DEFAULT_FILTERS, search: "query" } }); - const { container } = render(); + const { container } = renderSidebar(props); fireEvent.click(within(getDesktopPanel(container)).getByLabelText(/Clear search/i)); expect(props.onClearFilter).toHaveBeenCalledWith("search"); }); @@ -277,7 +261,7 @@ describe("TransactionFilterSidebar", () => { "calls onFilterChange('status', '%s')", (status) => { const props = buildProps(); - const { container } = render(); + const { container } = renderSidebar(props); fireEvent.change(getSelect(getDesktopPanel(container), /Status/i), { target: { value: status }, }); @@ -291,7 +275,7 @@ describe("TransactionFilterSidebar", () => { "calls onFilterChange('asset', '%s') on click", (asset) => { const props = buildProps(); - const { container } = render(); + const { container } = renderSidebar(props); const group = within(getDesktopPanel(container)).getByRole("group", { name: /Asset filter/i }); const label = asset === "all" ? /^All$/i : new RegExp(`^${asset}$`, "i"); fireEvent.click(within(group).getByRole("button", { name: label })); @@ -303,7 +287,7 @@ describe("TransactionFilterSidebar", () => { describe("date inputs", () => { it("calls onFilterChange('dateFrom', value)", () => { const props = buildProps(); - const { container } = render(); + const { container } = renderSidebar(props); fireEvent.change(getInput(getDesktopPanel(container), /From/i), { target: { value: "2024-01-15" }, }); @@ -312,7 +296,7 @@ describe("TransactionFilterSidebar", () => { it("calls onFilterChange('dateTo', value)", () => { const props = buildProps(); - const { container } = render(); + const { container } = renderSidebar(props); fireEvent.change(getInput(getDesktopPanel(container), /To/i), { target: { value: "2024-06-30" }, }); @@ -322,18 +306,14 @@ describe("TransactionFilterSidebar", () => { describe("Clear All button", () => { it("is disabled when hasActiveFilters=false", () => { - const { container } = render( - , - ); + const { container } = renderSidebar(buildProps({ hasActiveFilters: false })); expect( within(getDesktopPanel(container)).getByRole("button", { name: /Clear All Filters/i }), ).toBeDisabled(); }); it("is enabled when hasActiveFilters=true", () => { - const { container } = render( - , - ); + const { container } = renderSidebar(buildProps({ hasActiveFilters: true })); expect( within(getDesktopPanel(container)).getByRole("button", { name: /Clear All Filters/i }), ).not.toBeDisabled(); @@ -341,7 +321,7 @@ describe("TransactionFilterSidebar", () => { it("calls onClearAll when clicked while enabled", () => { const props = buildProps({ hasActiveFilters: true }); - const { container } = render(); + const { container } = renderSidebar(props); fireEvent.click( within(getDesktopPanel(container)).getByRole("button", { name: /Clear All Filters/i }), ); @@ -350,7 +330,7 @@ describe("TransactionFilterSidebar", () => { it("does NOT call onClearAll when clicked while disabled", () => { const props = buildProps({ hasActiveFilters: false }); - const { container } = render(); + const { container } = renderSidebar(props); fireEvent.click( within(getDesktopPanel(container)).getByRole("button", { name: /Clear All Filters/i }), ); @@ -364,114 +344,85 @@ describe("TransactionFilterSidebar", () => { describe("3 · Pending visual feedback", () => { describe("searchSyncPending", () => { it("sets aria-busy='true' on search input", () => { - const { container } = render( - , - ); + const { container } = renderSidebar(buildProps({ filters: { ...DEFAULT_FILTERS, search: "q" }, searchSyncPending: true })); expect(getInput(getDesktopPanel(container), /Search/i)).toHaveAttribute("aria-busy", "true"); }); it("sets aria-busy='false' on search input when not pending", () => { - const { container } = render(); + const { container } = renderSidebar(buildProps()); expect(getInput(getDesktopPanel(container), /Search/i)).toHaveAttribute("aria-busy", "false"); }); it("shows 'Applying to results…' hint with aria-live='polite'", () => { - const { container } = render( - , - ); + const { container } = renderSidebar(buildProps({ filters: { ...DEFAULT_FILTERS, search: "q" }, searchSyncPending: true })); const hint = within(getDesktopPanel(container)).getByText(/Applying to results/i); expect(hint).toBeInTheDocument(); expect(hint).toHaveAttribute("aria-live", "polite"); }); it("hides hint when not pending", () => { - const { container } = render(); + const { container } = renderSidebar(buildProps()); expect( within(getDesktopPanel(container)).queryByText(/Applying to results/i), ).not.toBeInTheDocument(); }); it("links search input to hint via aria-describedby", () => { - const { container } = render( - , - ); + const { container } = renderSidebar(buildProps({ filters: { ...DEFAULT_FILTERS, search: "q" }, searchSyncPending: true })); const panel = getDesktopPanel(container); const input = getInput(panel, /Search/i); const hintId = input.getAttribute("aria-describedby"); expect(hintId).toBeTruthy(); - // The hint

shares the same id suffix as the desktop input; query the - // full container because both desktop and mobile panels are in the DOM. - const hintEl = container.querySelector(`#${hintId}`); + // React's useId() ids contain `:` characters, which are invalid in an + // unescaped CSS selector — getElementById takes the raw id with no + // escaping concerns, unlike `container.querySelector('#' + hintId)`. + const hintEl = document.getElementById(hintId!); expect(hintEl).toBeInTheDocument(); expect(hintEl?.textContent).toMatch(/Applying to results/i); }); it("applies dashed border to search input", () => { - const { container } = render( - , - ); + const { container } = renderSidebar(buildProps({ filters: { ...DEFAULT_FILTERS, search: "q" }, searchSyncPending: true })); expect(getInput(getDesktopPanel(container), /Search/i).className).toContain("border-dashed"); }); it("does NOT apply dashed border when not pending", () => { - const { container } = render(); + const { container } = renderSidebar(buildProps()); expect(getInput(getDesktopPanel(container), /Search/i).className).not.toContain("border-dashed"); }); }); describe("isFilterPending", () => { it("sets aria-busy='true' on status select", () => { - const { container } = render( - , - ); + const { container } = renderSidebar(buildProps({ isFilterPending: true })); expect(getSelect(getDesktopPanel(container), /Status/i)).toHaveAttribute("aria-busy", "true"); }); it("applies dashed border to status select", () => { - const { container } = render( - , - ); + const { container } = renderSidebar(buildProps({ isFilterPending: true })); expect(getSelect(getDesktopPanel(container), /Status/i).className).toContain("border-dashed"); }); it("sets aria-busy='true' on dateFrom and dateTo inputs", () => { - const { container } = render( - , - ); + const { container } = renderSidebar(buildProps({ isFilterPending: true })); const panel = getDesktopPanel(container); expect(getInput(panel, /From/i)).toHaveAttribute("aria-busy", "true"); expect(getInput(panel, /To/i)).toHaveAttribute("aria-busy", "true"); }); it("applies dashed border to dateFrom when set + pending", () => { - const { container } = render( - , - ); + })); expect(getInput(getDesktopPanel(container), /From/i).className).toContain("border-dashed"); }); it("applies dashed border to dateTo when set + pending", () => { - const { container } = render( - , - ); + })); expect(getInput(getDesktopPanel(container), /To/i).className).toContain("border-dashed"); }); @@ -480,49 +431,33 @@ describe("TransactionFilterSidebar", () => { // is rendered inside it without aria-hidden. Until the component patch is applied // (aria-hidden="true" on the spinner wrapper), we locate the button via // aria-pressed="true" instead of by name. - const { container } = render( - , - ); + })); const group = within(getDesktopPanel(container)).getByRole("group", { name: /Asset filter/i }); const activeBtn = within(group).getByRole("button", { pressed: true }); expect(activeBtn.className).toContain("opacity-70"); }); it("does NOT apply opacity-70 to inactive asset buttons", () => { - const { container } = render( - , - ); + })); const group = within(getDesktopPanel(container)).getByRole("group", { name: /Asset filter/i }); // USDC is inactive — its name is unambiguous regardless of patch status expect(within(group).getByRole("button", { name: /^USDC$/i }).className).not.toContain("opacity-70"); }); it("active asset button has aria-pressed='true'", () => { - const { container } = render( - , - ); + const { container } = renderSidebar(buildProps({ filters: { ...DEFAULT_FILTERS, asset: "USDC" } })); const group = within(getDesktopPanel(container)).getByRole("group", { name: /Asset filter/i }); expect(within(group).getByRole("button", { name: /^USDC$/i })).toHaveAttribute("aria-pressed", "true"); }); it("inactive asset buttons have aria-pressed='false'", () => { - const { container } = render( - , - ); + const { container } = renderSidebar(buildProps({ filters: { ...DEFAULT_FILTERS, asset: "USDC" } })); const group = within(getDesktopPanel(container)).getByRole("group", { name: /Asset filter/i }); // XLM is inactive here — no spinner inside, name is unambiguous expect(within(group).getByRole("button", { name: /^XLM$/i })).toHaveAttribute("aria-pressed", "false"); @@ -532,38 +467,24 @@ describe("TransactionFilterSidebar", () => { describe("anyPending (searchSyncPending || isFilterPending)", () => { it("shows 'Clearing…' on Clear All when searchSyncPending=true", () => { - const { container } = render( - , - ); + const { container } = renderSidebar(buildProps({ hasActiveFilters: true, searchSyncPending: true })); expect(within(getDesktopPanel(container)).getByText(/Clearing…/i)).toBeInTheDocument(); }); it("shows 'Clearing…' on Clear All when isFilterPending=true", () => { - const { container } = render( - , - ); + const { container } = renderSidebar(buildProps({ hasActiveFilters: true, isFilterPending: true })); expect(within(getDesktopPanel(container)).getByText(/Clearing…/i)).toBeInTheDocument(); }); it("shows 'Clear All Filters' label when no pending flags are set", () => { - const { container } = render( - , - ); + const { container } = renderSidebar(buildProps({ hasActiveFilters: true })); const panel = getDesktopPanel(container); expect(within(panel).queryByText(/Clearing…/i)).not.toBeInTheDocument(); expect(within(panel).getByText(/Clear All Filters/i)).toBeInTheDocument(); }); it("SyncSpinner renders with role='status' while pending", () => { - const { container } = render( - , - ); + const { container } = renderSidebar(buildProps({ searchSyncPending: true, filters: { ...DEFAULT_FILTERS, search: "q" } })); expect(within(getDesktopPanel(container)).getAllByRole("status").length).toBeGreaterThan(0); }); }); @@ -573,45 +494,45 @@ describe("TransactionFilterSidebar", () => { describe("4 · Accessibility", () => { it("search input type is 'text'", () => { - const { container } = render(); + const { container } = renderSidebar(buildProps()); expect(getInput(getDesktopPanel(container), /Search/i)).toHaveAttribute("type", "text"); }); it("date inputs type is 'date'", () => { - const { container } = render(); + const { container } = renderSidebar(buildProps()); const panel = getDesktopPanel(container); expect(getInput(panel, /From/i)).toHaveAttribute("type", "date"); expect(getInput(panel, /To/i)).toHaveAttribute("type", "date"); }); it("search input has a descriptive placeholder", () => { - const { container } = render(); + const { container } = renderSidebar(buildProps()); expect( within(getDesktopPanel(container)).getByPlaceholderText(/ID or description/i), ).toBeInTheDocument(); }); it("decorative SVGs carry aria-hidden='true'", () => { - const { container } = render(); + const { container } = renderSidebar(buildProps()); expect( getDesktopPanel(container).querySelectorAll("svg[aria-hidden='true']").length, ).toBeGreaterThan(0); }); it("asset button group has role='group' with accessible aria-label", () => { - const { container } = render(); + const { container } = renderSidebar(buildProps()); expect( within(getDesktopPanel(container)).getByRole("group", { name: /Asset filter/i }), ).toBeInTheDocument(); }); it("Status label is linked to the select element", () => { - const { container } = render(); + const { container } = renderSidebar(buildProps()); expect(getSelect(getDesktopPanel(container), /Status/i).tagName).toBe("SELECT"); }); it("Search label is linked to a text input", () => { - const { container } = render(); + const { container } = renderSidebar(buildProps()); const el = getInput(getDesktopPanel(container), /Search/i); expect(el.tagName).toBe("INPUT"); expect(el).toHaveAttribute("type", "text"); @@ -622,7 +543,7 @@ describe("TransactionFilterSidebar", () => { describe("5 · Mobile drawer", () => { it("renders Close filters button inside the dialog", () => { - render(); + renderSidebar(buildProps({ isOpen: true })); expect( within(screen.getByRole("dialog")).getByLabelText(/Close filters/i), ).toBeInTheDocument(); @@ -630,14 +551,14 @@ describe("TransactionFilterSidebar", () => { it("calls onClose when Close filters button is clicked", () => { const props = buildProps({ isOpen: true }); - render(); + renderSidebar(props); fireEvent.click(screen.getByLabelText(/Close filters/i)); expect(props.onClose).toHaveBeenCalledTimes(1); }); it("calls onClose when the backdrop overlay is clicked", () => { const props = buildProps({ isOpen: true }); - const { container } = render(); + const { container } = renderSidebar(props); const backdrop = container.querySelector(".fixed.inset-0[aria-hidden='true']"); expect(backdrop).toBeInTheDocument(); fireEvent.click(backdrop!); @@ -646,7 +567,7 @@ describe("TransactionFilterSidebar", () => { it("mobile search input calls onFilterChange on change", () => { const props = buildProps({ isOpen: true }); - render(); + renderSidebar(props); fireEvent.change( within(screen.getByRole("dialog")).getByLabelText(/Search/i, { selector: "input" }), { target: { value: "mobile-query" } }, @@ -656,7 +577,7 @@ describe("TransactionFilterSidebar", () => { it("mobile Clear All button calls onClearAll", () => { const props = buildProps({ isOpen: true, hasActiveFilters: true }); - render(); + renderSidebar(props); fireEvent.click( within(screen.getByRole("dialog")).getByRole("button", { name: /Clear All Filters/i }), ); @@ -669,15 +590,13 @@ describe("TransactionFilterSidebar", () => { describe("6 · Edge cases", () => { it("renders without crashing when onClose is undefined (desktop-only usage)", () => { expect(() => - render(), + renderSidebar(buildProps({ onClose: undefined })), ).not.toThrow(); }); it("renders without crashing with every flag and filter active simultaneously", () => { expect(() => - render( - { searchSyncPending: true, isFilterPending: true, isOpen: true, - })} - />, - ), + })), ).not.toThrow(); }); it("'Clearing…' button stays disabled even while pending when hasActiveFilters=false", () => { - const { container } = render( - , - ); + const { container } = renderSidebar(buildProps({ hasActiveFilters: false, isFilterPending: true })); const btn = within(getDesktopPanel(container)).getByText(/Clearing…/i).closest("button"); expect(btn).toBeDisabled(); }); @@ -708,7 +621,7 @@ describe("TransactionFilterSidebar", () => { describe("7 · Screen reader & optimistic updates (#939 #940 #941)", () => { // #940 — desktop landmark role it("desktop panel has role='complementary' with accessible label", () => { - const { container } = render(); + const { container } = renderSidebar(buildProps()); const panel = getDesktopPanel(container); expect(panel).toHaveAttribute("role", "complementary"); expect(panel).toHaveAttribute("aria-label", "Transaction filters"); @@ -717,11 +630,7 @@ describe("TransactionFilterSidebar", () => { // #940 — SyncSpinner inside asset buttons must be decorative so it doesn't // corrupt the button's accessible name with "Syncing…" it("SyncSpinner wrapper inside active asset button has aria-hidden='true'", () => { - const { container } = render( - , - ); + const { container } = renderSidebar(buildProps({ filters: { ...DEFAULT_FILTERS, asset: "USDC" }, isFilterPending: true })); const panel = getDesktopPanel(container); const usdc = within(panel).getByRole("button", { name: /USDC/i }); // The span wrapping SyncSpinner must hide spinner text from the button label. @@ -731,7 +640,7 @@ describe("TransactionFilterSidebar", () => { // #941 — optimistic active-filter count badge it("shows no count badge when no filters are active", () => { - const { container } = render(); + const { container } = renderSidebar(buildProps()); const panel = getDesktopPanel(container); // Badge is the aria-hidden span next to the heading. const badge = within(panel).queryByText(/^\d+$/); @@ -739,20 +648,14 @@ describe("TransactionFilterSidebar", () => { }); it("shows count badge of 1 when only search is active", () => { - const { container } = render( - , - ); + const { container } = renderSidebar(buildProps({ filters: { ...DEFAULT_FILTERS, search: "tx-abc" }, hasActiveFilters: true })); const panel = getDesktopPanel(container); // aria-hidden badge with the count number expect(within(panel).getByText("1")).toBeInTheDocument(); }); it("shows correct count when multiple filters are active", () => { - const { container } = render( - { dateTo: "2024-12-31", }, hasActiveFilters: true, - })} - />, - ); + })); const panel = getDesktopPanel(container); expect(within(panel).getByText("5")).toBeInTheDocument(); }); // #940 — live region for screen-reader filter count announcements it("renders a polite live region for active filter count", () => { - const { container } = render(); + const { container } = renderSidebar(buildProps()); const panel = getDesktopPanel(container); const live = within(panel).getByRole("status"); expect(live).toBeInTheDocument(); @@ -778,32 +679,24 @@ describe("TransactionFilterSidebar", () => { }); it("live region is empty when no filters are active", () => { - const { container } = render(); + const { container } = renderSidebar(buildProps()); const panel = getDesktopPanel(container); const live = within(panel).getByRole("status"); expect(live.textContent).toBe(""); }); it("live region announces singular count text when one filter is active", () => { - const { container } = render( - , - ); + const { container } = renderSidebar(buildProps({ filters: { ...DEFAULT_FILTERS, search: "q" }, hasActiveFilters: true })); const panel = getDesktopPanel(container); const live = within(panel).getByRole("status"); expect(live).toHaveTextContent("1 filter active"); }); it("live region announces plural count text when multiple filters are active", () => { - const { container } = render( - , - ); + })); const panel = getDesktopPanel(container); const live = within(panel).getByRole("status"); expect(live).toHaveTextContent("2 filters active"); @@ -811,21 +704,17 @@ describe("TransactionFilterSidebar", () => { // #941 — Clear All accessible label reflects the active filter count it("Clear All button aria-label includes the active count", () => { - const { container } = render( - , - ); + })); const panel = getDesktopPanel(container); const btn = within(panel).getByRole("button", { name: /clear all 2 active filters/i }); expect(btn).toBeInTheDocument(); }); it("Clear All button aria-label is generic when no filters are active", () => { - const { container } = render(); + const { container } = renderSidebar(buildProps()); const panel = getDesktopPanel(container); const btn = within(panel).getByRole("button", { name: /clear all filters/i }); expect(btn).toBeInTheDocument(); @@ -834,10 +723,10 @@ describe("TransactionFilterSidebar", () => { // #940 — mobile dialog still has its landmark attributes it("mobile dialog retains role='dialog' and aria-modal when open", () => { - render(); + renderSidebar(buildProps({ isOpen: true })); const dialog = screen.getByRole("dialog"); expect(dialog).toHaveAttribute("aria-modal", "true"); - expect(dialog).toHaveAttribute("aria-label", "Filter sidebar"); + expect(dialog).toHaveAttribute("aria-label", "Transaction filters"); }); }); }); \ No newline at end of file diff --git a/frontend/src/components/TransactionFilterSidebar.tsx b/frontend/src/components/TransactionFilterSidebar.tsx index 8ad9106d..716515b3 100644 --- a/frontend/src/components/TransactionFilterSidebar.tsx +++ b/frontend/src/components/TransactionFilterSidebar.tsx @@ -3,7 +3,6 @@ import React, { useId } from "react"; import { useTranslations } from "next-intl"; import { motion, AnimatePresence, type Variants } from "framer-motion"; -import FilterSyncIndicator from "./FilterSyncIndicator"; // ─── Types ────────────────────────────────────────────────────────────────── @@ -63,7 +62,15 @@ const filterItemVariants: Variants = { // ─── Small reusable pieces ─────────────────────────────────────────────────── -/** Enhanced spinning ring with visual feedback while a filter is syncing to the URL. */ +/** + * Enhanced spinning ring with visual feedback while a filter is syncing to + * the URL. `aria-label` on the `role="status"` element is the spinner's + * entire accessible name — no separate `sr-only` text node is needed + * alongside it (screen readers use `aria-label`, not the element's text + * content, once it's set), and adding one only risked being announced + * twice when this component sits next to visible text repeating the same + * label (see the Clear All button's "Clearing…" state). + */ function SyncSpinner({ label = "Syncing…" }: { label?: string }) { return ( - {label} ); } @@ -276,7 +282,7 @@ function FilterContent({

@@ -384,7 +390,7 @@ function FilterContent({
@@ -448,7 +454,7 @@ function FilterContent({ variants={filterItemVariants} className="flex flex-col gap-2" > -

+

{t("asset.label")}

-

+

{t("dateRange.label")}

@@ -657,7 +663,7 @@ export default function TransactionFilterSidebar({
@@ -685,7 +691,7 @@ export default function TransactionFilterSidebar({ className="fixed inset-y-0 right-0 z-[110] w-[min(320px,90vw)] lg:hidden overflow-y-auto" role="dialog" aria-modal="true" - aria-label="Filter sidebar" + aria-label={t("landmarkLabel")} >