feat(select): enhance dropdown positioning and filtering functionality

- Introduced a new measureDropdownRect function to accurately position the dropdown menu based on viewport dimensions, improving usability.
- Updated the Select component to utilize the new dropdown measurement logic, ensuring consistent placement above or below the trigger.
- Refactored panelRect state management to use a more structured DropdownRect type, enhancing type safety and clarity.
- Improved event handling for dropdown visibility and positioning during scroll and resize events, ensuring a responsive user experience.
- Adjusted styling in related components for better alignment and visual consistency.
This commit is contained in:
AmirReza Jamali
2026-05-17 10:15:25 +03:30
parent de92137ec4
commit 0d8300493e
4 changed files with 133 additions and 178 deletions
@@ -43,16 +43,19 @@ export function TenderDetailsHeader({
<div className="flex w-full min-w-0 shrink-0 flex-col">
<div
className={cn(
"relative overflow-hidden rounded-[1.35rem] border border-stroke/55 bg-white/75 p-4 shadow-[0_12px_42px_-14px_rgba(15,23,42,0.14),0_0_0_1px_rgba(87,80,241,0.05)] backdrop-blur-md dark:border-white/[0.09] dark:bg-gradient-to-br dark:from-dark-2/95 dark:via-dark-3/55 dark:to-violet-600/[0.12] dark:shadow-[0_18px_50px_-18px_rgba(0,0,0,0.5),inset_0_1px_0_0_rgba(255,255,255,0.04)] sm:p-5",
"relative overflow-hidden rounded-[1.35rem] border border-stroke/55 p-4 sm:p-5",
"bg-white/75 shadow-[0_12px_42px_-14px_rgba(15,23,42,0.14),0_0_0_1px_rgba(87,80,241,0.05)] backdrop-blur-md",
"dark:border-white/[0.08] dark:bg-transparent dark:bg-gradient-to-br dark:from-dark-2 dark:via-dark-3 dark:to-violet-950/35",
"dark:shadow-none dark:backdrop-blur-none",
)}
>
<div
className="pointer-events-none absolute -right-6 -top-10 h-28 w-28 rounded-full bg-gradient-to-br from-primary/25 to-violet-500/10 blur-2xl dark:from-violet-500/30 dark:to-primary/5"
className="pointer-events-none absolute -right-6 -top-10 h-28 w-28 rounded-full bg-gradient-to-br from-primary/25 to-violet-500/10 blur-2xl dark:from-violet-500/15 dark:to-transparent dark:opacity-50"
aria-hidden
/>
<div className="relative space-y-4">
<div className="flex gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-primary/20 to-violet-600/10 text-primary shadow-inner ring-1 ring-primary/15 dark:from-primary/30 dark:to-violet-500/15 dark:ring-white/10">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-primary/20 to-violet-600/10 text-primary shadow-inner ring-1 ring-primary/15 dark:from-primary/30 dark:to-violet-500/15 dark:shadow-none dark:ring-white/10">
<GlobeIcon className="h-5 w-5 opacity-95" />
</div>
<div className="min-w-0 flex-1 pt-0.5">
@@ -79,7 +82,7 @@ export function TenderDetailsHeader({
className={cn(
"w-full shrink-0 space-y-0",
"[&_label]:sr-only",
"[&_button]:h-11 [&_button]:min-h-[2.75rem] [&_button]:rounded-full [&_button]:border-stroke/60 [&_button]:bg-white/95 [&_button]:py-0 [&_button]:text-sm [&_button]:shadow-sm dark:[&_button]:border-white/10 dark:[&_button]:bg-dark-2/92",
"[&_button]:h-11 [&_button]:min-h-[2.75rem] [&_button]:rounded-full [&_button]:border-stroke/60 [&_button]:bg-white/95 [&_button]:py-0 [&_button]:text-sm [&_button]:shadow-sm dark:[&_button]:border-white/10 dark:[&_button]:bg-dark-2 dark:[&_button]:shadow-none",
)}
value={selectedLanguage}
onChange={onLanguageChange as LanguageSelectOnChange}
+85 -132
View File
@@ -28,6 +28,50 @@ import {
export type { SelectSearchMode } from "./use-select-server-search";
const DROPDOWN_GAP_PX = 8;
const DROPDOWN_MIN_WIDTH_PX = 200;
const DROPDOWN_MAX_HEIGHT_PX = 352;
type DropdownPlacement = "above" | "below";
type DropdownRect = {
top: number;
left: number;
width: number;
placement: DropdownPlacement;
};
/** Anchor a fixed portal menu to the trigger; avoids native `<select>` popover bugs inside transformed ancestors. */
function measureDropdownRect(trigger: HTMLElement): DropdownRect {
const r = trigger.getBoundingClientRect();
const width = Math.max(r.width, DROPDOWN_MIN_WIDTH_PX);
const vv = window.visualViewport;
const viewportLeft = vv?.offsetLeft ?? 0;
const viewportTop = vv?.offsetTop ?? 0;
const viewportWidth = vv?.width ?? window.innerWidth;
const viewportHeight = vv?.height ?? window.innerHeight;
const left = Math.min(
Math.max(viewportLeft + 8, r.left),
viewportLeft + viewportWidth - width - 8,
);
const spaceBelow = viewportTop + viewportHeight - r.bottom - DROPDOWN_GAP_PX;
const spaceAbove = r.top - viewportTop - DROPDOWN_GAP_PX;
const placement: DropdownPlacement =
spaceBelow >= Math.min(DROPDOWN_MAX_HEIGHT_PX, 160) || spaceBelow >= spaceAbove
? "below"
: "above";
const top =
placement === "below"
? r.bottom + DROPDOWN_GAP_PX
: r.top - DROPDOWN_GAP_PX;
return { top, left, width, placement };
}
/** Match options by label or value; exact / prefix matches rank higher. */
export function filterSelectItems(
items: readonly ILabelValue[],
@@ -148,11 +192,7 @@ export function Select<T extends FieldValues>({
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [mounted, setMounted] = useState(false);
const [panelRect, setPanelRect] = useState<{
top: number;
left: number;
width: number;
} | null>(null);
const [panelRect, setPanelRect] = useState<DropdownRect | null>(null);
const error = errors && errors[name];
@@ -196,13 +236,7 @@ export function Select<T extends FieldValues>({
const syncPanelPosition = useCallback(() => {
const el = triggerRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const gap = 8;
setPanelRect({
top: r.bottom + gap,
left: r.left,
width: r.width,
});
setPanelRect(measureDropdownRect(el));
}, []);
useEffect(() => {
@@ -210,23 +244,32 @@ export function Select<T extends FieldValues>({
}, []);
useLayoutEffect(() => {
if (!searchable || !open) return;
if (!open) return;
syncPanelPosition();
}, [open, searchable, syncPanelPosition]);
const id = window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => syncPanelPosition());
});
return () => window.cancelAnimationFrame(id);
}, [open, syncPanelPosition]);
useEffect(() => {
if (!searchable || !open) return;
if (!open) return;
const onScrollOrResize = () => syncPanelPosition();
window.addEventListener("scroll", onScrollOrResize, true);
window.addEventListener("resize", onScrollOrResize);
const vv = window.visualViewport;
vv?.addEventListener("resize", onScrollOrResize);
vv?.addEventListener("scroll", onScrollOrResize);
return () => {
window.removeEventListener("scroll", onScrollOrResize, true);
window.removeEventListener("resize", onScrollOrResize);
vv?.removeEventListener("resize", onScrollOrResize);
vv?.removeEventListener("scroll", onScrollOrResize);
};
}, [open, searchable, syncPanelPosition]);
}, [open, syncPanelPosition]);
useEffect(() => {
if (!searchable || !open) return;
if (!open) return;
const onPointerDown = (e: MouseEvent | TouchEvent) => {
const t = e.target as Node;
if (rootRef.current?.contains(t)) return;
@@ -241,7 +284,7 @@ export function Select<T extends FieldValues>({
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("touchstart", onPointerDown);
};
}, [open, searchable, listboxId]);
}, [open, listboxId]);
useEffect(() => {
if (!searchable || !open) return;
@@ -280,19 +323,9 @@ export function Select<T extends FieldValues>({
triggerRef.current?.focus();
};
const selectSharedClasses = cn(
"w-full appearance-none rounded-xl border border-stroke/70 bg-white/90 px-5.5 py-3 shadow-[0_1px_2px_rgba(16,24,40,0.04),0_8px_20px_rgba(16,24,40,0.04)] outline-none backdrop-blur-[1px] transition-all duration-200 focus:border-primary/70 focus:bg-white focus:shadow-[0_0_0_2px_rgba(59,130,246,0.14),0_10px_24px_rgba(59,130,246,0.08)] active:border-primary/70 dark:border-dark-3/80 dark:bg-dark-2/85 dark:focus:border-primary/80 dark:focus:shadow-[0_0_0_2px_rgba(59,130,246,0.2),0_10px_24px_rgba(15,23,42,0.45)] [&>option]:text-dark-5 dark:[&>option]:text-dark-6",
isOptionSelected && "text-dark dark:text-white",
disabled &&
"cursor-not-allowed opacity-60 hover:border-stroke/70 dark:hover:border-dark-3/80",
error &&
"border-red/80 focus:border-red focus:shadow-[0_0_0_2px_rgba(239,68,68,0.16),0_10px_24px_rgba(239,68,68,0.1)] dark:border-red/80 dark:focus:shadow-[0_0_0_2px_rgba(239,68,68,0.24),0_10px_24px_rgba(127,29,29,0.45)]",
prefixIcon && "pl-11.5",
clearable && value && "pr-20",
);
const dropdownItems = searchable ? listItems : items;
const dropdown =
searchable &&
mounted &&
open &&
panelRect &&
@@ -306,17 +339,13 @@ export function Select<T extends FieldValues>({
style={{
position: "fixed",
top: panelRect.top,
left: (() => {
const w = Math.max(panelRect.width, 200);
if (typeof window === "undefined") return panelRect.left;
return Math.min(
Math.max(8, panelRect.left),
window.innerWidth - w - 8,
);
})(),
width: Math.max(panelRect.width, 200),
left: panelRect.left,
width: panelRect.width,
transform:
panelRect.placement === "above" ? "translateY(-100%)" : undefined,
}}
>
{searchable ? (
<div className="border-b border-stroke/50 p-2 dark:border-white/[0.08]">
<label htmlFor={`${listboxId}-search`} className="sr-only">
{searchPlaceholder}
@@ -342,22 +371,23 @@ export function Select<T extends FieldValues>({
className="w-full rounded-lg border border-stroke/60 bg-white/95 px-3 py-2 text-sm text-dark outline-none ring-primary/20 placeholder:text-dark-5 focus:border-primary/60 focus:ring-2 dark:border-white/10 dark:bg-dark-3/90 dark:text-white dark:placeholder:text-dark-6"
/>
</div>
) : null}
<ul className="max-h-[min(18rem,calc(100vh-10rem))] overflow-y-auto overscroll-contain p-1.5">
{isSearchLoading ? (
{searchable && isSearchLoading ? (
<li className="flex items-center justify-center gap-2 px-3 py-6 text-sm text-dark-5 dark:text-dark-6">
<span className="inline-flex h-4 w-4 animate-spin rounded-full border-2 border-primary/25 border-t-primary" />
Searching
</li>
) : isSearchError ? (
) : searchable && isSearchError ? (
<li className="px-3 py-6 text-center text-sm text-error">
Could not load options. Try again.
</li>
) : listItems.length === 0 ? (
) : dropdownItems.length === 0 ? (
<li className="px-3 py-6 text-center text-sm text-dark-5 dark:text-dark-6">
No matches.
{searchable ? "No matches." : "No options."}
</li>
) : (
listItems.map((item) => {
dropdownItems.map((item) => {
const v = String(item.value);
const isActive = v === String(value);
return (
@@ -370,16 +400,19 @@ export function Select<T extends FieldValues>({
data-value={v}
onClick={() => pickItem(v)}
className={cn(
"flex w-full items-center justify-between gap-2 rounded-lg px-3 py-2 text-start text-sm transition",
"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-start text-sm transition",
searchable ? "justify-between" : "justify-start",
isActive
? "bg-primary/12 font-medium text-primary dark:bg-primary/20 dark:text-white"
: "text-dark hover:bg-stroke/30 dark:text-white dark:hover:bg-white/[0.06]",
)}
>
<span className="min-w-0 truncate">{item.label}</span>
{searchable ? (
<span className="shrink-0 font-mono text-xs text-dark-5 dark:text-dark-6">
{v}
</span>
) : null}
</button>
</li>
);
@@ -390,7 +423,6 @@ export function Select<T extends FieldValues>({
document.body,
);
if (searchable) {
return (
<div ref={rootRef} className={cn("space-y-3", className)}>
<label
@@ -410,6 +442,7 @@ export function Select<T extends FieldValues>({
disabled={disabled}
tabIndex={-1}
aria-hidden
data-cy={dataCy}
className="sr-only"
onChange={(e) => {
if (controlledValue === undefined) {
@@ -425,7 +458,7 @@ export function Select<T extends FieldValues>({
{placeholder}
</option>
)}
{listItems.map((item) => (
{items.map((item) => (
<option key={String(item.value)} value={String(item.value)}>
{item.label}
</option>
@@ -446,7 +479,6 @@ export function Select<T extends FieldValues>({
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={open ? listboxId : undefined}
data-cy={dataCy}
onClick={() => {
if (disabled) return;
setOpen((o) => {
@@ -476,6 +508,7 @@ export function Select<T extends FieldValues>({
>
<span className="min-w-0 flex-1 truncate">
{value ? (
searchable ? (
<>
<span className="text-dark dark:text-white">
{selectedLabel}
@@ -484,6 +517,11 @@ export function Select<T extends FieldValues>({
{value}
</span>
</>
) : (
<span className="text-dark dark:text-white">
{selectedLabel}
</span>
)
) : (
placeholder || "Select…"
)}
@@ -520,91 +558,6 @@ export function Select<T extends FieldValues>({
{dropdown}
{error && (
<p className="text-red mt-1.5 text-sm font-medium">
{error.message as string}
</p>
)}
</div>
);
}
return (
<div className={cn("space-y-3", className)}>
<label
htmlFor={id}
className="block text-body-sm font-semibold capitalize text-dark dark:text-white"
>
{label}
{required && <span className="ml-1 select-none text-error">*</span>}
</label>
<div className="relative">
{prefixIcon && (
<div className="absolute left-4 top-1/2 -translate-y-1/2">
{prefixIcon}
</div>
)}
<select
{...props}
id={id}
name={name}
ref={ref}
disabled={disabled}
value={value}
onChange={(e) => {
if (controlledValue === undefined) {
setValue(e.target.value);
}
setIsOptionSelected(!!e.target.value);
onChange?.(e);
}}
onBlur={onBlur}
data-cy={dataCy}
className={selectSharedClasses}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{items.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
{clearable && value && (
<button
type="button"
onClick={handleClear}
data-cy={dataCy ? `${dataCy}-clear` : undefined}
className="absolute right-10 top-1/2 -translate-y-1/2 text-dark-5 transition hover:scale-110 hover:text-dark dark:text-dark-6 dark:hover:text-white"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 4L4 12M4 4L12 12"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
)}
<ChevronUpIcon className="pointer-events-none absolute right-4 top-1/2 -translate-y-1/2 rotate-180" />
</div>
{error && (
<p className="text-red mt-1.5 text-sm font-medium">
{error.message as string}
@@ -5,6 +5,7 @@ import { Select } from "@/components/FormElements/select";
import { Button } from "@/components/ui-elements/button";
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import { Currencies } from "@/constants/enums";
import { useMemo } from "react";
import {
FieldValues,
UseFormHandleSubmit,
@@ -12,7 +13,6 @@ import {
UseFormSetValue,
UseFormWatch,
} from "react-hook-form";
import { useMemo } from "react";
interface CompanyListFiltersProps {
filterRegister: UseFormRegister<FieldValues>;
@@ -60,7 +60,6 @@ const CompanyListFilters = ({
return (
<InlineListFiltersPanel
title="Filter companies"
description="Filter by identity, currency, or headcount — inline, no popup."
activeFilterCount={activeCount}
expandAriaLabel="Expand company filters"
collapseAriaLabel="Collapse company filters"
+5 -5
View File
@@ -175,7 +175,7 @@ export default function InlineListFiltersPanel({
aria-label={panelOpen ? collapseAriaLabel : expandAriaLabel}
data-cy={panelToggleDataCy}
className={cn(
"flex w-full flex-wrap items-start gap-x-3 gap-y-3 px-4 py-4 text-start transition-colors sm:items-center sm:justify-between sm:gap-4 sm:px-6 sm:py-5",
"flex w-full flex-col gap-3 px-4 py-4 text-start transition-colors sm:flex-row sm:items-center sm:justify-between sm:gap-4 sm:px-6 sm:py-5",
"rounded-[1.65rem] border border-transparent",
"hover:border-stroke/50 hover:bg-white/70 dark:hover:border-white/[0.08] dark:hover:bg-dark-3/50",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-dark-2",
@@ -187,8 +187,8 @@ export default function InlineListFiltersPanel({
<span className="mt-0.5 flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-primary to-violet-600 text-white sm:mt-0">
<SearchIcon className="h-5 w-5 opacity-95" />
</span>
<div className="min-w-0">
<span className="block text-lg font-bold tracking-tight text-dark dark:text-white">
<div className="min-w-0 flex-1">
<span className="block text-balance text-lg font-bold leading-snug tracking-tight text-dark dark:text-white">
{title}
</span>
<IsVisible condition={!!description}>
@@ -198,7 +198,7 @@ export default function InlineListFiltersPanel({
</IsVisible>
</div>
</div>
<div className="ml-auto flex shrink-0 items-center gap-2">
<div className="flex w-full min-w-0 items-center justify-between gap-3 pl-14 sm:ml-auto sm:w-auto sm:shrink-0 sm:justify-end sm:pl-0">
{activeFilterCount > 0 ? (
<div className="flex items-center gap-2 rounded-full border border-primary/25 bg-primary/10 px-3 py-1.5 dark:border-primary/35 dark:bg-primary/15">
<span className="text-xs font-semibold uppercase tracking-wide text-primary">
@@ -206,7 +206,7 @@ export default function InlineListFiltersPanel({
</span>
</div>
) : (
<span className="inline-flex items-center rounded-full border border-stroke/50 bg-white/60 px-3 py-1.5 text-xs font-medium text-dark-5 dark:border-white/10 dark:bg-dark-3/80 dark:text-dark-6">
<span className="inline-flex min-w-0 max-w-[calc(100%-2rem)] items-center truncate rounded-full border border-stroke/50 bg-white/60 px-3 py-1.5 text-xs font-medium text-dark-5 dark:border-white/10 dark:bg-dark-3/80 dark:text-dark-6">
No filters applied
</span>
)}