11'use client' ;
22
3- import { ChevronDown } from 'lucide-react' ;
3+ import { Check , ChevronDown } from 'lucide-react' ;
44import { useTranslations } from 'next-intl' ;
5- import { useEffect , useState } from 'react' ;
5+ import { useEffect , useId , useRef , useState } from 'react' ;
66
77import { cn } from '@/lib/utils' ;
88
@@ -38,6 +38,19 @@ export function Pagination({
3838 const accentSoft = hexToRgba ( accentColor , 0.16 ) ;
3939 const accentGlow = hexToRgba ( accentColor , 0.22 ) ;
4040 const [ isMobile , setIsMobile ] = useState ( false ) ;
41+ const [ isPageSizeOpen , setIsPageSizeOpen ] = useState ( false ) ;
42+ const pageSizeInstanceId = useId ( ) ;
43+ const pageSizeLabelId = `${ pageSizeInstanceId } -label` ;
44+ const pageSizeTriggerId = `${ pageSizeInstanceId } -trigger` ;
45+ const pageSizeListboxId = `${ pageSizeInstanceId } -listbox` ;
46+ const pageSizeTriggerRef = useRef < HTMLButtonElement > ( null ) ;
47+ const pageSizeDropdownRef = useRef < HTMLDivElement > ( null ) ;
48+ const pageSizeOptionRefs = useRef < Array < HTMLButtonElement | null > > ( [ ] ) ;
49+ const selectedPageSizeIndex = pageSizeOptions . findIndex (
50+ size => size === pageSize
51+ ) ;
52+ const normalizedSelectedPageSizeIndex =
53+ selectedPageSizeIndex >= 0 ? selectedPageSizeIndex : 0 ;
4154
4255 useEffect ( ( ) => {
4356 const media = window . matchMedia ( '(max-width: 640px)' ) ;
@@ -47,6 +60,122 @@ export function Pagination({
4760 return ( ) => media . removeEventListener ( 'change' , update ) ;
4861 } , [ ] ) ;
4962
63+ useEffect ( ( ) => {
64+ if ( ! isPageSizeOpen ) return ;
65+
66+ const handlePointerDown = ( event : MouseEvent | TouchEvent ) => {
67+ if (
68+ pageSizeDropdownRef . current &&
69+ ! pageSizeDropdownRef . current . contains ( event . target as Node )
70+ ) {
71+ setIsPageSizeOpen ( false ) ;
72+ }
73+ } ;
74+
75+ const handleEscape = ( event : KeyboardEvent ) => {
76+ if ( event . key === 'Escape' ) {
77+ setIsPageSizeOpen ( false ) ;
78+ }
79+ } ;
80+
81+ document . addEventListener ( 'mousedown' , handlePointerDown ) ;
82+ document . addEventListener ( 'touchstart' , handlePointerDown ) ;
83+ document . addEventListener ( 'keydown' , handleEscape ) ;
84+
85+ return ( ) => {
86+ document . removeEventListener ( 'mousedown' , handlePointerDown ) ;
87+ document . removeEventListener ( 'touchstart' , handlePointerDown ) ;
88+ document . removeEventListener ( 'keydown' , handleEscape ) ;
89+ } ;
90+ } , [ isPageSizeOpen ] ) ;
91+
92+ useEffect ( ( ) => {
93+ if ( ! isPageSizeOpen ) return ;
94+ const frame = window . requestAnimationFrame ( ( ) => {
95+ pageSizeOptionRefs . current [ normalizedSelectedPageSizeIndex ] ?. focus ( ) ;
96+ } ) ;
97+ return ( ) => window . cancelAnimationFrame ( frame ) ;
98+ } , [ isPageSizeOpen , normalizedSelectedPageSizeIndex ] ) ;
99+
100+ const focusPageSizeOption = ( index : number ) => {
101+ if ( pageSizeOptions . length === 0 ) return ;
102+ const clamped = Math . max ( 0 , Math . min ( index , pageSizeOptions . length - 1 ) ) ;
103+ pageSizeOptionRefs . current [ clamped ] ?. focus ( ) ;
104+ } ;
105+
106+ const selectPageSize = ( size : number ) => {
107+ onPageSizeChange ?.( size ) ;
108+ setIsPageSizeOpen ( false ) ;
109+ window . requestAnimationFrame ( ( ) => {
110+ pageSizeTriggerRef . current ?. focus ( ) ;
111+ } ) ;
112+ } ;
113+
114+ const handlePageSizeTriggerKeyDown = (
115+ event : React . KeyboardEvent < HTMLButtonElement >
116+ ) => {
117+ if (
118+ event . key !== 'ArrowDown' &&
119+ event . key !== 'ArrowUp' &&
120+ event . key !== 'Home' &&
121+ event . key !== 'End'
122+ ) {
123+ return ;
124+ }
125+
126+ event . preventDefault ( ) ;
127+ if ( ! isPageSizeOpen ) {
128+ setIsPageSizeOpen ( true ) ;
129+ }
130+
131+ window . requestAnimationFrame ( ( ) => {
132+ if ( event . key === 'ArrowDown' ) {
133+ focusPageSizeOption ( normalizedSelectedPageSizeIndex + 1 ) ;
134+ return ;
135+ }
136+ if ( event . key === 'ArrowUp' ) {
137+ focusPageSizeOption ( normalizedSelectedPageSizeIndex - 1 ) ;
138+ return ;
139+ }
140+ if ( event . key === 'Home' ) {
141+ focusPageSizeOption ( 0 ) ;
142+ return ;
143+ }
144+ focusPageSizeOption ( pageSizeOptions . length - 1 ) ;
145+ } ) ;
146+ } ;
147+
148+ const handlePageSizeOptionKeyDown = (
149+ event : React . KeyboardEvent < HTMLButtonElement > ,
150+ index : number
151+ ) => {
152+ if ( event . key === 'ArrowDown' ) {
153+ event . preventDefault ( ) ;
154+ focusPageSizeOption ( index + 1 ) ;
155+ return ;
156+ }
157+ if ( event . key === 'ArrowUp' ) {
158+ event . preventDefault ( ) ;
159+ focusPageSizeOption ( index - 1 ) ;
160+ return ;
161+ }
162+ if ( event . key === 'Home' ) {
163+ event . preventDefault ( ) ;
164+ focusPageSizeOption ( 0 ) ;
165+ return ;
166+ }
167+ if ( event . key === 'End' ) {
168+ event . preventDefault ( ) ;
169+ focusPageSizeOption ( pageSizeOptions . length - 1 ) ;
170+ return ;
171+ }
172+ if ( event . key === 'Escape' ) {
173+ event . preventDefault ( ) ;
174+ setIsPageSizeOpen ( false ) ;
175+ pageSizeTriggerRef . current ?. focus ( ) ;
176+ }
177+ } ;
178+
50179 const effectiveTotalPages = Math . max ( totalPages , 1 ) ;
51180
52181 const getPageNumbers = ( ) : ( number | 'ellipsis' ) [ ] => {
@@ -183,32 +312,84 @@ export function Pagination({
183312 { onPageSizeChange && pageSizeOptions . length > 1 && (
184313 < div className = "absolute top-1/2 right-0 hidden -translate-y-1/2 items-center gap-2 lg:flex" >
185314 < label
186- htmlFor = "qa-page-size"
315+ id = { pageSizeLabelId }
187316 className = "text-xs font-medium whitespace-nowrap text-gray-600 dark:text-gray-300"
188317 >
189318 { t ( 'itemsPerPage' ) }
190319 </ label >
191320 < div
192- className = "relative overflow-hidden rounded-lg border bg-white/90 shadow-sm dark:bg-neutral-900/80"
321+ ref = { pageSizeDropdownRef }
322+ className = "relative rounded-lg border bg-white/90 shadow-sm dark:bg-neutral-900/80"
193323 style = { {
194324 borderColor : accentColor ,
195325 boxShadow : `0 0 0 1px ${ accentSoft } ` ,
196326 } }
197327 >
198- < select
199- id = "qa-page-size"
200- value = { pageSize }
201- onChange = { event => onPageSizeChange ( Number ( event . target . value ) ) }
328+ < button
329+ ref = { pageSizeTriggerRef }
330+ id = { pageSizeTriggerId }
331+ type = "button"
332+ onClick = { ( ) => setIsPageSizeOpen ( prev => ! prev ) }
333+ onKeyDown = { handlePageSizeTriggerKeyDown }
202334 aria-label = { t ( 'itemsPerPageAria' ) }
203- className = "min-w-20 appearance-none bg-transparent px-3 py-2 pr-9 text-sm font-medium text-gray-800 transition-colors outline-none hover:bg-[var(--qa-accent-soft)] focus:bg-[var(--qa-accent-soft)] dark:text-gray-200"
335+ aria-haspopup = "listbox"
336+ aria-expanded = { isPageSizeOpen }
337+ aria-controls = { pageSizeListboxId }
338+ aria-labelledby = { `${ pageSizeLabelId } ${ pageSizeTriggerId } ` }
339+ className = "flex min-w-20 items-center justify-between gap-2 rounded-lg bg-transparent px-3 py-2 text-sm font-medium text-gray-800 transition-colors outline-none hover:bg-[var(--qa-accent-soft)] focus:bg-[var(--qa-accent-soft)] dark:text-gray-200"
204340 >
205- { pageSizeOptions . map ( size => (
206- < option key = { size } value = { size } >
207- { size }
208- </ option >
209- ) ) }
210- </ select >
211- < ChevronDown className = "pointer-events-none absolute top-1/2 right-3 h-4 w-4 -translate-y-1/2 text-gray-600 dark:text-gray-300" />
341+ < span > { pageSize } </ span >
342+ < ChevronDown
343+ className = { cn (
344+ 'h-4 w-4 text-gray-600 transition-transform dark:text-gray-300' ,
345+ isPageSizeOpen && 'rotate-180'
346+ ) }
347+ />
348+ </ button >
349+
350+ { isPageSizeOpen && (
351+ < ul
352+ id = { pageSizeListboxId }
353+ role = "listbox"
354+ aria-labelledby = { pageSizeLabelId }
355+ className = "absolute right-0 bottom-[calc(100%+8px)] z-[80] min-w-full rounded-lg border bg-white/95 p-1 shadow-lg backdrop-blur-md dark:bg-neutral-900/90"
356+ style = { {
357+ borderColor : accentColor ,
358+ boxShadow : `0 10px 24px ${ accentGlow } ` ,
359+ } }
360+ >
361+ { pageSizeOptions . map ( ( size , optionIndex ) => {
362+ const selected = size === pageSize ;
363+ return (
364+ < li key = { size } >
365+ < button
366+ ref = { node => {
367+ pageSizeOptionRefs . current [ optionIndex ] = node ;
368+ } }
369+ type = "button"
370+ role = "option"
371+ aria-selected = { selected }
372+ onClick = { ( ) => selectPageSize ( size ) }
373+ onKeyDown = { event =>
374+ handlePageSizeOptionKeyDown ( event , optionIndex )
375+ }
376+ className = { cn (
377+ 'flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left text-sm font-medium transition-colors' ,
378+ selected
379+ ? 'bg-[var(--qa-accent-soft)] text-gray-900 dark:text-gray-100'
380+ : 'text-gray-700 hover:bg-[var(--qa-accent-soft)] dark:text-gray-300'
381+ ) }
382+ >
383+ < span > { size } </ span >
384+ { selected && (
385+ < Check className = "h-3.5 w-3.5 text-gray-700 dark:text-gray-100" />
386+ ) }
387+ </ button >
388+ </ li >
389+ ) ;
390+ } ) }
391+ </ ul >
392+ ) }
212393 </ div >
213394 </ div >
214395 ) }
0 commit comments