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 ──────────────────────────────────────────── */
|
||||||
.table-wrapper {
|
.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 ──────────────────────────────── */
|
/* ─── Loading skeleton ──────────────────────────────── */
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { ChevronDown, Check } from 'lucide-react';
|
import { ChevronDown, Check } from 'lucide-react';
|
||||||
|
|
||||||
export type SelectOption = { value: string; label: string; disabled?: boolean };
|
export type SelectOption = { value: string; label: string; disabled?: boolean };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lightweight custom dropdown. Unlike a native <select>, its option list is
|
* Lightweight custom dropdown. The option list is rendered into a body-level
|
||||||
* absolutely anchored right below the trigger, so it can never render detached
|
* portal and anchored to the trigger with fixed positioning, so it is never
|
||||||
* at the top of the screen (a common mobile/emulator quirk). Flips above the
|
* clipped by an `overflow` ancestor (e.g. a horizontally-scrollable table
|
||||||
* trigger automatically when there isn't enough room below.
|
* wrapper). Flips above the trigger automatically when there isn't enough room
|
||||||
|
* below.
|
||||||
*/
|
*/
|
||||||
export function Select({
|
export function Select({
|
||||||
value,
|
value,
|
||||||
@@ -32,37 +34,68 @@ export function Select({
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
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 rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const listRef = useRef<HTMLUListElement>(null);
|
||||||
const selected = options.find((o) => o.value === value);
|
const selected = options.find((o) => o.value === value);
|
||||||
|
|
||||||
const close = useCallback(() => setOpen(false), []);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
const isInside = (target: Node | null) =>
|
||||||
|
!!target &&
|
||||||
|
(rootRef.current?.contains(target) || listRef.current?.contains(target));
|
||||||
const onDocClick = (e: MouseEvent) => {
|
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) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') close();
|
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('mousedown', onDocClick);
|
||||||
document.addEventListener('keydown', onKey);
|
document.addEventListener('keydown', onKey);
|
||||||
|
window.addEventListener('scroll', onScroll, true);
|
||||||
|
window.addEventListener('resize', close);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousedown', onDocClick);
|
document.removeEventListener('mousedown', onDocClick);
|
||||||
document.removeEventListener('keydown', onKey);
|
document.removeEventListener('keydown', onKey);
|
||||||
|
window.removeEventListener('scroll', onScroll, true);
|
||||||
|
window.removeEventListener('resize', close);
|
||||||
};
|
};
|
||||||
}, [open, close]);
|
}, [open, close]);
|
||||||
|
|
||||||
const toggle = (e: React.MouseEvent) => {
|
const toggle = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
if (!open && rootRef.current) {
|
if (open) {
|
||||||
const rect = rootRef.current.getBoundingClientRect();
|
setOpen(false);
|
||||||
const spaceBelow = window.innerHeight - rect.bottom;
|
return;
|
||||||
setFlipUp(spaceBelow < 240 && rect.top > spaceBelow);
|
|
||||||
}
|
}
|
||||||
setOpen((o) => !o);
|
updatePosition();
|
||||||
|
setOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -86,12 +119,18 @@ export function Select({
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && menuRect && createPortal(
|
||||||
<ul
|
<ul
|
||||||
|
ref={listRef}
|
||||||
role="listbox"
|
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 ${
|
style={{
|
||||||
flipUp ? 'bottom-full mb-1' : 'top-full mt-1'
|
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) => {
|
{options.map((opt) => {
|
||||||
const isSelected = opt.value === value;
|
const isSelected = opt.value === value;
|
||||||
@@ -120,7 +159,8 @@ export function Select({
|
|||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>,
|
||||||
|
document.body,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user