diff --git a/src/components/DistributionFilterToolbar/DistributionFilterToolbar.a11y-rtl.test.tsx b/src/components/DistributionFilterToolbar/DistributionFilterToolbar.a11y-rtl.test.tsx new file mode 100644 index 0000000..40ab19a --- /dev/null +++ b/src/components/DistributionFilterToolbar/DistributionFilterToolbar.a11y-rtl.test.tsx @@ -0,0 +1,357 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { axe, toHaveNoViolations } from 'jest-axe'; +import { DistributionFilterToolbar } from './DistributionFilterToolbar'; +import type { DistributionFilterState } from './DistributionFilterToolbar.types'; + +expect.extend(toHaveNoViolations); + +const baseFilters: DistributionFilterState = { + searchQuery: '', + dateRange: 'all', + issuer: 'all', + region: 'all', + status: 'all', + segmentBy: 'none', + compareMode: false, +}; + +const manyActiveFilters: DistributionFilterState = { + searchQuery: 'PO-2026', + dateRange: '90d', + issuer: 'Nexus Cloud Series A', + region: 'North America', + status: 'failed', + segmentBy: 'region', + compareMode: true, +}; + +function setup(filters: DistributionFilterState = baseFilters) { + const onFilterChange = vi.fn(); + const onResetFilters = vi.fn(); + render( + + ); + return { onFilterChange, onResetFilters }; +} + +describe('DistributionFilterToolbar — focus trap & return focus', () => { + it('moves focus into the date popover when it opens', async () => { + const user = userEvent.setup(); + setup(); + + const trigger = screen.getByTestId('filter-trigger-date'); + await user.click(trigger); + + const panel = document.getElementById('date-filter-panel'); + expect(panel).not.toBeNull(); + expect(panel).toContainElement(document.activeElement as HTMLElement); + }); + + it('traps Tab within the open popover instead of escaping to the toolbar', async () => { + const user = userEvent.setup(); + setup(); + + await user.click(screen.getByTestId('filter-trigger-date')); + const panel = document.getElementById('date-filter-panel') as HTMLElement; + const focusablesInPanel = within(panel).getAllByRole('button'); + const last = focusablesInPanel[focusablesInPanel.length - 1]; + + last.focus(); + await user.tab(); + + expect(panel).toContainElement(document.activeElement as HTMLElement); + }); + + it('returns focus to the trigger button when the popover is closed via Escape', async () => { + const user = userEvent.setup(); + setup(); + + const trigger = screen.getByTestId('filter-trigger-date'); + await user.click(trigger); + expect(document.getElementById('date-filter-panel')).not.toBeNull(); + + await user.keyboard('{Escape}'); + + expect(document.getElementById('date-filter-panel')).toBeNull(); + expect(document.activeElement).toBe(trigger); + }); + + it('returns focus to the mobile filter trigger when the mobile sheet is closed', async () => { + const user = userEvent.setup(); + setup(); + + const trigger = screen.getByTestId('mobile-filter-trigger'); + await user.click(trigger); + + const sheet = screen.getByTestId('mobile-filter-sheet'); + expect(sheet).toContainElement(document.activeElement as HTMLElement); + + const closeBtn = screen.getByLabelText('Close filters'); + await user.click(closeBtn); + + expect(screen.queryByTestId('mobile-filter-sheet')).not.toBeInTheDocument(); + expect(document.activeElement).toBe(trigger); + }); + + it('has no axe violations while a popover is open', async () => { + const user = userEvent.setup(); + const { container } = render( + + ); + await user.click(screen.getByTestId('filter-trigger-status')); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); + +describe('DistributionFilterToolbar — RTL layout', () => { + const originalDir = document.documentElement.dir; + + beforeEach(() => { + document.documentElement.dir = 'rtl'; + }); + + afterEach(() => { + document.documentElement.dir = originalDir; + }); + + it('renders active filter pills in reading order under dir="rtl"', () => { + setup(manyActiveFilters); + + const pillsRow = screen.getByTestId('active-filter-pills-row'); + const pillTestIds = ['pill-search', 'pill-date', 'pill-issuer', 'pill-region', 'pill-status', 'pill-segment']; + + const renderedOrder = pillTestIds.filter((id) => within(pillsRow).queryByTestId(id)); + expect(renderedOrder).toEqual(pillTestIds); + }); + + it('anchors the presets popover to the inline-end edge, not a hardcoded physical side', async () => { + const user = userEvent.setup(); + setup(manyActiveFilters); + + await user.click(screen.getByTestId('filter-presets-trigger')); + const panel = document.getElementById('presets-filter-panel') as HTMLElement; + + expect(panel.className).toContain('filter-popover-panel--end-aligned'); + expect(panel.style.left).toBe(''); + expect(panel.style.right).toBe(''); + }); + + it('has no axe violations when rendered under dir="rtl"', async () => { + const { container } = render( + + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); + +describe('DistributionFilterToolbar — remaining branch coverage', () => { + it('uses the savedPresets prop instead of localStorage/defaults when provided', async () => { + const user = userEvent.setup(); + const propPresets = [ + { id: 'preset-from-prop', name: 'From Prop', filterState: { status: 'processing' } }, + ]; + render( + + ); + + await user.click(screen.getByTestId('filter-presets-trigger')); + + expect(screen.getByTestId('preset-option-preset-from-prop')).toHaveTextContent('From Prop'); + }); + + it('loads presets from localStorage when no savedPresets prop is given', async () => { + const user = userEvent.setup(); + window.localStorage.setItem( + 'revora_distribution_saved_filters', + JSON.stringify([{ id: 'preset-from-storage', name: 'From Storage', filterState: { region: 'Global' } }]) + ); + + try { + render( + + ); + + await user.click(screen.getByTestId('filter-presets-trigger')); + + expect(screen.getByTestId('preset-option-preset-from-storage')).toHaveTextContent('From Storage'); + } finally { + window.localStorage.removeItem('revora_distribution_saved_filters'); + } + }); + + it('selects a non-custom date range option and closes the popover', async () => { + const user = userEvent.setup(); + const { onFilterChange } = setup(); + + await user.click(screen.getByTestId('filter-trigger-date')); + await user.click(screen.getByText('Last 30 Days')); + + expect(onFilterChange).toHaveBeenCalledWith(expect.objectContaining({ dateRange: '30d' })); + expect(document.getElementById('date-filter-panel')).toBeNull(); + }); + + it('toggles each popover closed by clicking its own trigger a second time', async () => { + const user = userEvent.setup(); + setup(); + + const triggers = [ + ['filter-trigger-date', 'date-filter-panel'], + ['filter-trigger-issuer', 'issuer-filter-panel'], + ['filter-trigger-region', 'region-filter-panel'], + ['filter-trigger-status', 'status-filter-panel'], + ] as const; + + for (const [triggerTestId, panelId] of triggers) { + const trigger = screen.getByTestId(triggerTestId); + await user.click(trigger); + expect(document.getElementById(panelId)).not.toBeNull(); + + await user.click(trigger); + expect(document.getElementById(panelId)).toBeNull(); + } + }); + + it('toggles the presets popover closed by clicking its own trigger a second time', async () => { + const user = userEvent.setup(); + setup(manyActiveFilters); + + const trigger = screen.getByTestId('filter-presets-trigger'); + await user.click(trigger); + expect(document.getElementById('presets-filter-panel')).not.toBeNull(); + + await user.click(trigger); + expect(document.getElementById('presets-filter-panel')).toBeNull(); + }); + + it('resets issuer, region, and status back to "all" via their respective "All ..." option', async () => { + const user = userEvent.setup(); + const { onFilterChange } = setup({ ...baseFilters, issuer: 'Nexus Cloud Series A', region: 'North America', status: 'failed' }); + + await user.click(screen.getByTestId('filter-trigger-issuer')); + await user.click(screen.getByRole('button', { name: /all issuers/i })); + expect(onFilterChange).toHaveBeenCalledWith(expect.objectContaining({ issuer: 'all' })); + + await user.click(screen.getByTestId('filter-trigger-region')); + await user.click(screen.getByRole('button', { name: /all regions/i })); + expect(onFilterChange).toHaveBeenCalledWith(expect.objectContaining({ region: 'all' })); + + await user.click(screen.getByTestId('filter-trigger-status')); + await user.click(screen.getByRole('button', { name: /all statuses/i })); + expect(onFilterChange).toHaveBeenCalledWith(expect.objectContaining({ status: 'all' })); + }); + + it('does nothing when Save Current Filters is clicked with an empty preset name', async () => { + const user = userEvent.setup(); + setup({ ...baseFilters, status: 'processing' }); + + await user.click(screen.getByTestId('filter-presets-trigger')); + // Name input left empty. + await user.click(screen.getByTestId('save-preset-btn')); + + // Popover should still be open — the no-op early return means the + // save flow (which would close the popover) never ran. + expect(document.getElementById('presets-filter-panel')).not.toBeNull(); + }); + + it('saves a preset without an onSavePreset callback and does not throw', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByTestId('filter-presets-trigger')); + await user.type(screen.getByTestId('preset-name-input'), 'No Callback Preset'); + await user.click(screen.getByTestId('save-preset-btn')); + + expect(document.getElementById('presets-filter-panel')).toBeNull(); + }); + + it('closes the mobile sheet from the Apply Filters button', async () => { + const user = userEvent.setup(); + setup(manyActiveFilters); + + await user.click(screen.getByTestId('mobile-filter-trigger')); + expect(screen.getByTestId('mobile-filter-sheet')).toBeInTheDocument(); + + await user.click(screen.getByTestId('mobile-apply-btn')); + + expect(screen.queryByTestId('mobile-filter-sheet')).not.toBeInTheDocument(); + }); +}); + +describe('DistributionFilterToolbar — many active filters', () => { + it('renders all active filter pills plus Clear All without truncation', () => { + setup(manyActiveFilters); + + expect(screen.getByTestId('pill-search')).toBeInTheDocument(); + expect(screen.getByTestId('pill-date')).toBeInTheDocument(); + expect(screen.getByTestId('pill-issuer')).toBeInTheDocument(); + expect(screen.getByTestId('pill-region')).toBeInTheDocument(); + expect(screen.getByTestId('pill-status')).toBeInTheDocument(); + expect(screen.getByTestId('pill-segment')).toBeInTheDocument(); + expect(screen.getByTestId('pill-compare')).toBeInTheDocument(); + expect(screen.getByTestId('filter-clear-all-btn')).toBeInTheDocument(); + }); + + it('shows the total active count on the mobile "Filters (n)" trigger', () => { + setup(manyActiveFilters); + + const trigger = screen.getByTestId('mobile-filter-trigger'); + expect(trigger).toHaveTextContent('Filters (7)'); + }); + + it('announces filter changes via the aria-live status region', async () => { + const user = userEvent.setup(); + setup(baseFilters); + + const liveRegion = screen.getByTestId('filter-live-region'); + expect(liveRegion).toHaveTextContent(''); + + await user.type(screen.getByTestId('filter-search-input'), 'x'); + + expect(liveRegion).toHaveTextContent('Search filter updated'); + }); + + it('has no axe violations with the maximum realistic set of active filters', async () => { + const { container } = render( + + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/src/components/DistributionFilterToolbar/DistributionFilterToolbar.css b/src/components/DistributionFilterToolbar/DistributionFilterToolbar.css index b167c6f..6894561 100644 --- a/src/components/DistributionFilterToolbar/DistributionFilterToolbar.css +++ b/src/components/DistributionFilterToolbar/DistributionFilterToolbar.css @@ -1,4 +1,4 @@ -/* DistributionFilterToolbar.css — Issue #214 */ +/* DistributionFilterToolbar.css — Issue #437 (RTL + a11y hardening pass on #214) */ :root { --filter-toolbar-bg: rgba(15, 23, 42, 0.85); @@ -8,6 +8,19 @@ --filter-pill-text: #38bdf8; } +/* Screen-reader-only utility for the aria-live filter-status region */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + /* Main Toolbar Wrapper */ .distribution-filter-toolbar { background: var(--filter-toolbar-bg); @@ -54,7 +67,9 @@ background: rgba(30, 41, 59, 0.6); border: 1px solid var(--filter-toolbar-border); border-radius: var(--radius-md, 0.5rem); - padding: 0.5rem 0.75rem 0.5rem 2.25rem; + padding-block: 0.5rem; + padding-inline-end: 0.75rem; + padding-inline-start: 2.25rem; color: #ffffff; font-size: var(--font-size-sm, 0.875rem); transition: all 0.15s ease; @@ -68,7 +83,7 @@ .filter-search-icon { position: absolute; - left: 0.75rem; + inset-inline-start: 0.75rem; top: 50%; transform: translateY(-50%); color: var(--text-muted, #cbd5e1); @@ -126,7 +141,7 @@ .filter-popover-panel { position: absolute; top: calc(100% + 0.5rem); - left: 0; + inset-inline-start: 0; min-width: 240px; background: rgba(15, 23, 42, 0.98); border: 1px solid var(--filter-toolbar-border); @@ -140,6 +155,16 @@ animation: filterPopoverFadeIn 0.15s ease-out; } +/* Variant for panels that should hang off the end (trailing) edge instead + of the start edge — e.g. the presets panel, which sits at the far end + of row 2. Replaces the previous inline `style={{ right: 0, left: 'auto' }}` + which was LTR-only and never flipped in RTL layouts. */ +.filter-popover-panel--end-aligned { + inset-inline-start: auto; + inset-inline-end: 0; + width: 280px; +} + @keyframes filterPopoverFadeIn { from { opacity: 0; @@ -158,7 +183,7 @@ padding: 0.5rem 0.75rem; color: var(--text-main, #e5e7eb); font-size: var(--font-size-sm, 0.875rem); - text-align: left; + text-align: start; cursor: pointer; display: flex; align-items: center; @@ -204,8 +229,8 @@ display: flex; align-items: center; gap: var(--spacing-md, 1rem); - border-left: 1px solid var(--filter-toolbar-border); - padding-left: var(--spacing-md, 1rem); + border-inline-start: 1px solid var(--filter-toolbar-border); + padding-inline-start: var(--spacing-md, 1rem); } .filter-select { @@ -249,7 +274,7 @@ content: ''; position: absolute; top: 2px; - left: 2px; + inset-inline-start: 2px; width: 16px; height: 16px; border-radius: 50%; @@ -265,6 +290,10 @@ transform: translateX(16px); } +[dir='rtl'] .compare-toggle-checkbox:checked::before { + transform: translateX(-16px); +} + .compare-toggle-checkbox:focus-visible { outline: 2px solid var(--primary, #3b82f6); outline-offset: 2px; @@ -398,8 +427,7 @@ .mobile-filter-sheet { position: fixed; bottom: 0; - left: 0; - right: 0; + inset-inline: 0; max-height: 85vh; background: rgba(15, 23, 42, 0.98); border-top-left-radius: var(--radius-xl, 1rem); @@ -477,7 +505,11 @@ } } -/* RTL Support for tag wrapping */ -[dir="rtl"] .active-pills-list { - flex-direction: row-reverse; -} +/* NOTE: the previous `[dir="rtl"] .active-pills-list { flex-direction: + row-reverse; }` rule has been removed. `flex-direction: row` already + flows along the inline axis, which flips automatically under `dir="rtl"` + — the explicit row-reverse override was double-flipping the pill order + back to LTR visual order inside an RTL context. All other positioning + in this file now uses logical properties (inset-inline-*, padding- + inline-*, border-inline-*) so it flips correctly without any [dir] + overrides at all. */ diff --git a/src/components/DistributionFilterToolbar/DistributionFilterToolbar.tsx b/src/components/DistributionFilterToolbar/DistributionFilterToolbar.tsx index fddf604..edc9334 100644 --- a/src/components/DistributionFilterToolbar/DistributionFilterToolbar.tsx +++ b/src/components/DistributionFilterToolbar/DistributionFilterToolbar.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import './DistributionFilterToolbar.css'; +import { useFocusTrap } from '../../hooks/useFocusTrap'; import { DistributionFilterState, DateRangeOption, @@ -33,6 +34,17 @@ const PRESET_DEFAULTS: FilterPreset[] = [ }, ]; +// Human-readable labels used only for the aria-live announcement. +const FIELD_LABELS: Record = { + searchQuery: 'Search', + dateRange: 'Date range', + issuer: 'Issuer', + region: 'Region', + status: 'Status', + segmentBy: 'Segmentation', + compareMode: 'Compare mode', +}; + export const DistributionFilterToolbar: React.FC = ({ filters, onFilterChange, @@ -46,6 +58,7 @@ export const DistributionFilterToolbar: React.FC const [openPopover, setOpenPopover] = useState<'date' | 'issuer' | 'region' | 'status' | 'presets' | null>(null); const [isMobileSheetOpen, setIsMobileSheetOpen] = useState(false); const [presetInputName, setPresetInputName] = useState(''); + const [liveMessage, setLiveMessage] = useState(''); const [customPresets, setCustomPresets] = useState(() => { if (savedPresets) return savedPresets; try { @@ -60,7 +73,16 @@ export const DistributionFilterToolbar: React.FC }); const toolbarRef = useRef(null); - const mobileSheetRef = useRef(null); + + // Focus traps — one per popover panel, plus the mobile sheet. Each moves + // focus in on open, traps Tab/Shift+Tab, and returns focus to the trigger + // that opened it on close (Escape, outside click, or Apply/Close). + const datePanelRef = useFocusTrap(openPopover === 'date'); + const issuerPanelRef = useFocusTrap(openPopover === 'issuer'); + const regionPanelRef = useFocusTrap(openPopover === 'region'); + const statusPanelRef = useFocusTrap(openPopover === 'status'); + const presetsPanelRef = useFocusTrap(openPopover === 'presets'); + const mobileSheetRef = useFocusTrap(isMobileSheetOpen); // Close popovers when clicking outside or pressing Escape useEffect(() => { @@ -92,6 +114,8 @@ export const DistributionFilterToolbar: React.FC ...filters, [key]: value, }); + const label = FIELD_LABELS[key as string] ?? String(key); + setLiveMessage(`${label} filter updated`); }, [filters, onFilterChange] ); @@ -129,6 +153,7 @@ export const DistributionFilterToolbar: React.FC } setPresetInputName(''); setOpenPopover(null); + setLiveMessage(`Preset "${newPreset.name}" saved`); }; // Load a preset @@ -139,6 +164,12 @@ export const DistributionFilterToolbar: React.FC }); setOpenPopover(null); setIsMobileSheetOpen(false); + setLiveMessage(`Preset "${preset.name}" applied`); + }; + + const handleResetFilters = () => { + onResetFilters(); + setLiveMessage('All filters cleared'); }; return ( @@ -149,6 +180,11 @@ export const DistributionFilterToolbar: React.FC aria-label="Distribution filters and segmentation" data-testid="distribution-filter-toolbar" > + {/* Visually-hidden live region announcing filter changes to screen readers */} +
+ {liveMessage} +
+ {/* Mobile Collapsible Button (< 768px) */} {/* Desktop Toolbar Row (>= 768px) */} @@ -172,7 +205,7 @@ export const DistributionFilterToolbar: React.FC
{/* Search Bar */}
- 🔍 +
{openPopover === 'date' && ( -
+