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:
keyhan
2026-06-18 13:29:08 +03:30
parent 585fdbc9b7
commit a3a7e17cca
2 changed files with 57 additions and 17 deletions
+1 -1
View File
@@ -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 ──────────────────────────────── */
+56 -16
View File
@@ -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>
);