Skip to content

Commit 36d35d7

Browse files
(SP: 1) [Frontend] Q&A Per-Page Dropdown UI Polish (Style Consistency + Footer Overlap Fix) (#445)
* fix(qa): unify per-page dropdown style and fix footer overlap * fix(qa): scope page-size dropdown IDs per instance and add keyboard listbox navigation
1 parent 902ccde commit 36d35d7

File tree

2 files changed

+200
-18
lines changed

2 files changed

+200
-18
lines changed

frontend/components/q&a/Pagination.tsx

Lines changed: 197 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
'use client';
22

3-
import { ChevronDown } from 'lucide-react';
3+
import { Check, ChevronDown } from 'lucide-react';
44
import { useTranslations } from 'next-intl';
5-
import { useEffect, useState } from 'react';
5+
import { useEffect, useId, useRef, useState } from 'react';
66

77
import { 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
)}

frontend/components/tests/q&a/pagination.test.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,9 @@ describe('Pagination', () => {
143143
/>
144144
);
145145

146-
const select = screen.getByLabelText('itemsPerPageAria');
147-
fireEvent.change(select, { target: { value: '40' } });
146+
const trigger = screen.getByLabelText('itemsPerPageAria');
147+
fireEvent.click(trigger);
148+
fireEvent.click(screen.getByRole('option', { name: '40' }));
148149

149150
expect(onPageSizeChange).toHaveBeenCalledWith(40);
150151
});

0 commit comments

Comments
 (0)