From 17becd20d1b73e6ce8e5fe0819ee47cbb270a763 Mon Sep 17 00:00:00 2001 From: Viktor Svertoka Date: Sun, 12 Apr 2026 21:05:02 +0300 Subject: [PATCH 1/2] fix(qa): unify per-page dropdown style and fix footer overlap --- frontend/components/q&a/Pagination.tsx | 111 +++++++++++++++--- .../components/tests/q&a/pagination.test.tsx | 5 +- 2 files changed, 98 insertions(+), 18 deletions(-) diff --git a/frontend/components/q&a/Pagination.tsx b/frontend/components/q&a/Pagination.tsx index 4cd8b5c8..dd5dbafb 100644 --- a/frontend/components/q&a/Pagination.tsx +++ b/frontend/components/q&a/Pagination.tsx @@ -1,8 +1,8 @@ 'use client'; -import { ChevronDown } from 'lucide-react'; +import { Check, ChevronDown } from 'lucide-react'; import { useTranslations } from 'next-intl'; -import { useEffect, useState } from 'react'; +import { useEffect, useId, useRef, useState } from 'react'; import { cn } from '@/lib/utils'; @@ -38,6 +38,9 @@ export function Pagination({ const accentSoft = hexToRgba(accentColor, 0.16); const accentGlow = hexToRgba(accentColor, 0.22); const [isMobile, setIsMobile] = useState(false); + const [isPageSizeOpen, setIsPageSizeOpen] = useState(false); + const pageSizeDropdownRef = useRef(null); + const pageSizeListboxId = useId(); useEffect(() => { const media = window.matchMedia('(max-width: 640px)'); @@ -47,6 +50,35 @@ export function Pagination({ return () => media.removeEventListener('change', update); }, []); + useEffect(() => { + if (!isPageSizeOpen) return; + + const handlePointerDown = (event: MouseEvent | TouchEvent) => { + if ( + pageSizeDropdownRef.current && + !pageSizeDropdownRef.current.contains(event.target as Node) + ) { + setIsPageSizeOpen(false); + } + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsPageSizeOpen(false); + } + }; + + document.addEventListener('mousedown', handlePointerDown); + document.addEventListener('touchstart', handlePointerDown); + document.addEventListener('keydown', handleEscape); + + return () => { + document.removeEventListener('mousedown', handlePointerDown); + document.removeEventListener('touchstart', handlePointerDown); + document.removeEventListener('keydown', handleEscape); + }; + }, [isPageSizeOpen]); + const effectiveTotalPages = Math.max(totalPages, 1); const getPageNumbers = (): (number | 'ellipsis')[] => { @@ -183,32 +215,79 @@ export function Pagination({ {onPageSizeChange && pageSizeOptions.length > 1 && (
- - + {pageSize} + + + + {isPageSizeOpen && ( +
    + {pageSizeOptions.map(size => { + const selected = size === pageSize; + return ( +
  • + +
  • + ); + })} +
+ )}
)} diff --git a/frontend/components/tests/q&a/pagination.test.tsx b/frontend/components/tests/q&a/pagination.test.tsx index 865b83a3..199b4ee2 100644 --- a/frontend/components/tests/q&a/pagination.test.tsx +++ b/frontend/components/tests/q&a/pagination.test.tsx @@ -143,8 +143,9 @@ describe('Pagination', () => { /> ); - const select = screen.getByLabelText('itemsPerPageAria'); - fireEvent.change(select, { target: { value: '40' } }); + const trigger = screen.getByLabelText('itemsPerPageAria'); + fireEvent.click(trigger); + fireEvent.click(screen.getByRole('option', { name: '40' })); expect(onPageSizeChange).toHaveBeenCalledWith(40); }); From 4e4f4088fc19f18b44f52b844e7556d755de1e82 Mon Sep 17 00:00:00 2001 From: Viktor Svertoka Date: Sun, 12 Apr 2026 21:32:15 +0300 Subject: [PATCH 2/2] fix(qa): scope page-size dropdown IDs per instance and add keyboard listbox navigation --- frontend/components/q&a/Pagination.tsx | 122 +++++++++++++++++++++++-- 1 file changed, 112 insertions(+), 10 deletions(-) diff --git a/frontend/components/q&a/Pagination.tsx b/frontend/components/q&a/Pagination.tsx index dd5dbafb..9342bd39 100644 --- a/frontend/components/q&a/Pagination.tsx +++ b/frontend/components/q&a/Pagination.tsx @@ -39,8 +39,18 @@ export function Pagination({ const accentGlow = hexToRgba(accentColor, 0.22); const [isMobile, setIsMobile] = useState(false); const [isPageSizeOpen, setIsPageSizeOpen] = useState(false); + const pageSizeInstanceId = useId(); + const pageSizeLabelId = `${pageSizeInstanceId}-label`; + const pageSizeTriggerId = `${pageSizeInstanceId}-trigger`; + const pageSizeListboxId = `${pageSizeInstanceId}-listbox`; + const pageSizeTriggerRef = useRef(null); const pageSizeDropdownRef = useRef(null); - const pageSizeListboxId = useId(); + const pageSizeOptionRefs = useRef>([]); + const selectedPageSizeIndex = pageSizeOptions.findIndex( + size => size === pageSize + ); + const normalizedSelectedPageSizeIndex = + selectedPageSizeIndex >= 0 ? selectedPageSizeIndex : 0; useEffect(() => { const media = window.matchMedia('(max-width: 640px)'); @@ -79,6 +89,93 @@ export function Pagination({ }; }, [isPageSizeOpen]); + useEffect(() => { + if (!isPageSizeOpen) return; + const frame = window.requestAnimationFrame(() => { + pageSizeOptionRefs.current[normalizedSelectedPageSizeIndex]?.focus(); + }); + return () => window.cancelAnimationFrame(frame); + }, [isPageSizeOpen, normalizedSelectedPageSizeIndex]); + + const focusPageSizeOption = (index: number) => { + if (pageSizeOptions.length === 0) return; + const clamped = Math.max(0, Math.min(index, pageSizeOptions.length - 1)); + pageSizeOptionRefs.current[clamped]?.focus(); + }; + + const selectPageSize = (size: number) => { + onPageSizeChange?.(size); + setIsPageSizeOpen(false); + window.requestAnimationFrame(() => { + pageSizeTriggerRef.current?.focus(); + }); + }; + + const handlePageSizeTriggerKeyDown = ( + event: React.KeyboardEvent + ) => { + if ( + event.key !== 'ArrowDown' && + event.key !== 'ArrowUp' && + event.key !== 'Home' && + event.key !== 'End' + ) { + return; + } + + event.preventDefault(); + if (!isPageSizeOpen) { + setIsPageSizeOpen(true); + } + + window.requestAnimationFrame(() => { + if (event.key === 'ArrowDown') { + focusPageSizeOption(normalizedSelectedPageSizeIndex + 1); + return; + } + if (event.key === 'ArrowUp') { + focusPageSizeOption(normalizedSelectedPageSizeIndex - 1); + return; + } + if (event.key === 'Home') { + focusPageSizeOption(0); + return; + } + focusPageSizeOption(pageSizeOptions.length - 1); + }); + }; + + const handlePageSizeOptionKeyDown = ( + event: React.KeyboardEvent, + index: number + ) => { + if (event.key === 'ArrowDown') { + event.preventDefault(); + focusPageSizeOption(index + 1); + return; + } + if (event.key === 'ArrowUp') { + event.preventDefault(); + focusPageSizeOption(index - 1); + return; + } + if (event.key === 'Home') { + event.preventDefault(); + focusPageSizeOption(0); + return; + } + if (event.key === 'End') { + event.preventDefault(); + focusPageSizeOption(pageSizeOptions.length - 1); + return; + } + if (event.key === 'Escape') { + event.preventDefault(); + setIsPageSizeOpen(false); + pageSizeTriggerRef.current?.focus(); + } + }; + const effectiveTotalPages = Math.max(totalPages, 1); const getPageNumbers = (): (number | 'ellipsis')[] => { @@ -215,7 +312,7 @@ export function Pagination({ {onPageSizeChange && pageSizeOptions.length > 1 && (