fix(frontend): horizontal-scroll table wrapper + portal Select dropdown
Make .table-wrapper overflow-x-auto so wide rows (long name + email) stay reachable — the actions column (deactivate/reset password) was clipped before. Render the Select option list in a body-level portal with fixed positioning so it is never clipped by the scroll container. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -110,7 +110,7 @@
|
||||
|
||||
/* ─── Table ──────────────────────────────────────────── */
|
||||
.table-wrapper {
|
||||
@apply overflow-hidden rounded-2xl border border-gray-200/80 bg-white shadow-sm;
|
||||
@apply overflow-x-auto rounded-2xl border border-gray-200/80 bg-white shadow-sm;
|
||||
}
|
||||
|
||||
/* ─── Loading skeleton ──────────────────────────────── */
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChevronDown, Check } from 'lucide-react';
|
||||
|
||||
export type SelectOption = { value: string; label: string; disabled?: boolean };
|
||||
|
||||
/**
|
||||
* Lightweight custom dropdown. Unlike a native <select>, its option list is
|
||||
* absolutely anchored right below the trigger, so it can never render detached
|
||||
* at the top of the screen (a common mobile/emulator quirk). Flips above the
|
||||
* trigger automatically when there isn't enough room below.
|
||||
* Lightweight custom dropdown. The option list is rendered into a body-level
|
||||
* portal and anchored to the trigger with fixed positioning, so it is never
|
||||
* clipped by an `overflow` ancestor (e.g. a horizontally-scrollable table
|
||||
* wrapper). Flips above the trigger automatically when there isn't enough room
|
||||
* below.
|
||||
*/
|
||||
export function Select({
|
||||
value,
|
||||
@@ -32,37 +34,68 @@ export function Select({
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [flipUp, setFlipUp] = useState(false);
|
||||
const [menuRect, setMenuRect] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
flipUp: boolean;
|
||||
} | null>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
const selected = options.find((o) => o.value === value);
|
||||
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!rootRef.current) return;
|
||||
const rect = rootRef.current.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const flipUp = spaceBelow < 240 && rect.top > spaceBelow;
|
||||
setMenuRect({
|
||||
top: flipUp ? rect.top - 4 : rect.bottom + 4,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
flipUp,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const isInside = (target: Node | null) =>
|
||||
!!target &&
|
||||
(rootRef.current?.contains(target) || listRef.current?.contains(target));
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) close();
|
||||
if (!isInside(e.target as Node)) close();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') close();
|
||||
};
|
||||
// Fixed-positioned menu would detach from the trigger on scroll, so close it
|
||||
// (unless the scroll happens inside the option list itself).
|
||||
const onScroll = (e: Event) => {
|
||||
if (!listRef.current?.contains(e.target as Node)) close();
|
||||
};
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
document.addEventListener('keydown', onKey);
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
window.addEventListener('resize', close);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDocClick);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('scroll', onScroll, true);
|
||||
window.removeEventListener('resize', close);
|
||||
};
|
||||
}, [open, close]);
|
||||
|
||||
const toggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (disabled) return;
|
||||
if (!open && rootRef.current) {
|
||||
const rect = rootRef.current.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
setFlipUp(spaceBelow < 240 && rect.top > spaceBelow);
|
||||
if (open) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
setOpen((o) => !o);
|
||||
updatePosition();
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -86,12 +119,18 @@ export function Select({
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
{open && menuRect && createPortal(
|
||||
<ul
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
className={`absolute z-50 left-0 right-0 max-h-60 overflow-auto rounded-xl border border-gray-200 bg-white py-1 shadow-lg ring-1 ring-black/5 ${
|
||||
flipUp ? 'bottom-full mb-1' : 'top-full mt-1'
|
||||
}`}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: menuRect.top,
|
||||
left: menuRect.left,
|
||||
width: menuRect.width,
|
||||
transform: menuRect.flipUp ? 'translateY(-100%)' : undefined,
|
||||
}}
|
||||
className="z-[60] max-h-60 overflow-auto rounded-xl border border-gray-200 bg-white py-1 shadow-lg ring-1 ring-black/5"
|
||||
>
|
||||
{options.map((opt) => {
|
||||
const isSelected = opt.value === value;
|
||||
@@ -120,7 +159,8 @@ export function Select({
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</ul>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user