diff --git a/frontend/components/q&a/Pagination.tsx b/frontend/components/q&a/Pagination.tsx index 4cd8b5c8..9342bd39 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,19 @@ 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 pageSizeInstanceId = useId(); + const pageSizeLabelId = `${pageSizeInstanceId}-label`; + const pageSizeTriggerId = `${pageSizeInstanceId}-trigger`; + const pageSizeListboxId = `${pageSizeInstanceId}-listbox`; + const pageSizeTriggerRef = useRef(null); + const pageSizeDropdownRef = useRef(null); + const pageSizeOptionRefs = useRef>([]); + const selectedPageSizeIndex = pageSizeOptions.findIndex( + size => size === pageSize + ); + const normalizedSelectedPageSizeIndex = + selectedPageSizeIndex >= 0 ? selectedPageSizeIndex : 0; useEffect(() => { const media = window.matchMedia('(max-width: 640px)'); @@ -47,6 +60,122 @@ 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]); + + 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')[] => { @@ -183,32 +312,84 @@ export function Pagination({ {onPageSizeChange && pageSizeOptions.length > 1 && (
- - + {pageSize} + + + + {isPageSizeOpen && ( +
    + {pageSizeOptions.map((size, optionIndex) => { + 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); });