used layout effect for the select

This commit is contained in:
suryacp23 2026-07-29 17:50:27 +05:30
parent b6f2872703
commit f9ea463ea8

View File

@ -1,4 +1,4 @@
import { useState, useRef, useEffect, useCallback, type SelectHTMLAttributes } from 'react'; import { useState, useRef, useEffect, useLayoutEffect, useCallback, type SelectHTMLAttributes } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { ChevronDown, Search, X, Check } from 'lucide-react'; import { ChevronDown, Search, X, Check } from 'lucide-react';
import { cn } from '../../lib/cn'; import { cn } from '../../lib/cn';
@ -63,10 +63,17 @@ export function Select({
const spaceBelow = window.innerHeight - rect.bottom; const spaceBelow = window.innerHeight - rect.bottom;
const openUpwards = spaceBelow < dropdownHeight && rect.top > dropdownHeight; const openUpwards = spaceBelow < dropdownHeight && rect.top > dropdownHeight;
const calculatedWidth = Math.max(rect.width, 280);
let leftPos = rect.left;
if (leftPos + calculatedWidth > window.innerWidth - 12) {
leftPos = Math.max(12, window.innerWidth - calculatedWidth - 12);
}
setDropdownStyle({ setDropdownStyle({
position: 'fixed', position: 'fixed',
left: `${rect.left}px`, left: `${leftPos}px`,
width: `${rect.width}px`, width: `${calculatedWidth}px`,
maxWidth: 'calc(100vw - 24px)',
zIndex: 999999, zIndex: 999999,
...(openUpwards ...(openUpwards
? { bottom: `${window.innerHeight - rect.top + 4}px` } ? { bottom: `${window.innerHeight - rect.top + 4}px` }
@ -75,7 +82,7 @@ export function Select({
} }
}, []); }, []);
useEffect(() => { useLayoutEffect(() => {
if (isOpen) { if (isOpen) {
updatePosition(); updatePosition();
const handleScrollOrResize = () => updatePosition(); const handleScrollOrResize = () => updatePosition();
@ -85,6 +92,8 @@ export function Select({
window.removeEventListener('resize', handleScrollOrResize); window.removeEventListener('resize', handleScrollOrResize);
window.removeEventListener('scroll', handleScrollOrResize, true); window.removeEventListener('scroll', handleScrollOrResize, true);
}; };
} else {
setDropdownStyle({});
} }
}, [isOpen, updatePosition]); }, [isOpen, updatePosition]);
@ -102,7 +111,26 @@ export function Select({
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, []);
const handleSelect = (val: string) => { const handleOpenToggle = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!disabled) {
const next = !isOpen;
if (next) {
updatePosition();
onDropdownOpen?.();
} else {
setDropdownStyle({});
}
setIsOpen(next);
}
};
const handleSelect = (val: string, e?: React.MouseEvent) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
if (rest.onChange) { if (rest.onChange) {
const syntheticEvent = { const syntheticEvent = {
target: { value: val, name: rest.name, id: rest.id }, target: { value: val, name: rest.name, id: rest.id },
@ -125,13 +153,7 @@ export function Select({
{/* Trigger Box */} {/* Trigger Box */}
<div <div
onClick={() => { onClick={handleOpenToggle}
if (!disabled) {
const next = !isOpen;
setIsOpen(next);
if (next) onDropdownOpen?.();
}
}}
className={cn( className={cn(
"relative bg-card rounded-md h-[42px] border px-3 flex items-center justify-between cursor-pointer transition-all duration-150 select-none", "relative bg-card rounded-md h-[42px] border px-3 flex items-center justify-between cursor-pointer transition-all duration-150 select-none",
disabled && "opacity-60 cursor-not-allowed bg-slate-50", disabled && "opacity-60 cursor-not-allowed bg-slate-50",
@ -145,7 +167,7 @@ export function Select({
</div> </div>
{/* Portaled Dropdown Menu Overlay */} {/* Portaled Dropdown Menu Overlay */}
{isOpen && !disabled && createPortal( {isOpen && !disabled && Boolean(dropdownStyle.position) && createPortal(
<div <div
ref={dropdownRef} ref={dropdownRef}
style={dropdownStyle} style={dropdownStyle}
@ -166,7 +188,11 @@ export function Select({
{searchQuery && ( {searchQuery && (
<button <button
type="button" type="button"
onClick={() => setSearchQuery('')} onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setSearchQuery('');
}}
className="text-muted hover:text-strong p-0.5 rounded cursor-pointer" className="text-muted hover:text-strong p-0.5 rounded cursor-pointer"
> >
<X size={12} /> <X size={12} />
@ -182,13 +208,14 @@ export function Select({
return ( return (
<div <div
key={opt.value} key={opt.value}
onClick={() => handleSelect(opt.value)} onClick={(e) => handleSelect(opt.value, e)}
title={opt.label}
className={cn( className={cn(
"px-3 py-2 text-sm flex items-center justify-between cursor-pointer transition-colors", "px-3 py-2 text-sm flex items-center justify-between cursor-pointer transition-colors",
isSelected ? "bg-blue-50/50 text-blue-700 font-semibold" : "hover:bg-black/5 text-strong" isSelected ? "bg-blue-50/50 text-blue-700 font-semibold" : "hover:bg-black/5 text-strong"
)} )}
> >
<span className="truncate">{opt.label}</span> <span className="flex-1 break-words text-xs sm:text-sm leading-normal mr-2">{opt.label}</span>
{isSelected && <Check size={14} className="text-blue-600 shrink-0 ml-2" />} {isSelected && <Check size={14} className="text-blue-600 shrink-0 ml-2" />}
</div> </div>
); );
@ -271,13 +298,21 @@ export function MultiSelect({
const currentValues = Array.isArray(value) ? value : []; const currentValues = Array.isArray(value) ? value : [];
const handleOpenToggle = () => { const handleOpenToggle = (e?: React.MouseEvent) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
if (!disabled) { if (!disabled) {
if (!isOpen) { const next = !isOpen;
if (next) {
setDraftValues(currentValues); setDraftValues(currentValues);
setSearchQuery(''); setSearchQuery('');
updatePosition();
} else {
setDropdownStyle({});
} }
setIsOpen(!isOpen); setIsOpen(next);
} }
}; };
@ -292,10 +327,17 @@ export function MultiSelect({
const spaceBelow = window.innerHeight - rect.bottom; const spaceBelow = window.innerHeight - rect.bottom;
const openUpwards = spaceBelow < dropdownHeight && rect.top > dropdownHeight; const openUpwards = spaceBelow < dropdownHeight && rect.top > dropdownHeight;
const calculatedWidth = Math.max(rect.width, 280);
let leftPos = rect.left;
if (leftPos + calculatedWidth > window.innerWidth - 12) {
leftPos = Math.max(12, window.innerWidth - calculatedWidth - 12);
}
setDropdownStyle({ setDropdownStyle({
position: 'fixed', position: 'fixed',
left: `${rect.left}px`, left: `${leftPos}px`,
width: `${rect.width}px`, width: `${calculatedWidth}px`,
maxWidth: 'calc(100vw - 24px)',
zIndex: 999999, zIndex: 999999,
...(openUpwards ...(openUpwards
? { bottom: `${window.innerHeight - rect.top + 4}px` } ? { bottom: `${window.innerHeight - rect.top + 4}px` }
@ -304,7 +346,7 @@ export function MultiSelect({
} }
}, []); }, []);
useEffect(() => { useLayoutEffect(() => {
if (isOpen) { if (isOpen) {
updatePosition(); updatePosition();
const handleScrollOrResize = () => updatePosition(); const handleScrollOrResize = () => updatePosition();
@ -314,6 +356,8 @@ export function MultiSelect({
window.removeEventListener('resize', handleScrollOrResize); window.removeEventListener('resize', handleScrollOrResize);
window.removeEventListener('scroll', handleScrollOrResize, true); window.removeEventListener('scroll', handleScrollOrResize, true);
}; };
} else {
setDropdownStyle({});
} }
}, [isOpen, updatePosition]); }, [isOpen, updatePosition]);
@ -331,18 +375,30 @@ export function MultiSelect({
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, []);
const toggleOption = (val: string) => { const toggleOption = (val: string, e?: React.MouseEvent) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
setDraftValues((prev) => setDraftValues((prev) =>
prev.includes(val) ? prev.filter((v) => v !== val) : [...prev, val] prev.includes(val) ? prev.filter((v) => v !== val) : [...prev, val]
); );
}; };
const handleApply = () => { const handleApply = (e?: React.MouseEvent) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
onChange?.(draftValues); onChange?.(draftValues);
setIsOpen(false); setIsOpen(false);
}; };
const handleClear = () => { const handleClear = (e?: React.MouseEvent) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
setDraftValues([]); setDraftValues([]);
}; };
@ -382,7 +438,7 @@ export function MultiSelect({
</div> </div>
{/* Portaled Dropdown Menu Overlay */} {/* Portaled Dropdown Menu Overlay */}
{isOpen && !disabled && createPortal( {isOpen && !disabled && Boolean(dropdownStyle.position) && createPortal(
<div <div
ref={dropdownRef} ref={dropdownRef}
style={dropdownStyle} style={dropdownStyle}
@ -403,7 +459,11 @@ export function MultiSelect({
{searchQuery && ( {searchQuery && (
<button <button
type="button" type="button"
onClick={() => setSearchQuery('')} onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setSearchQuery('');
}}
className="text-muted hover:text-strong p-0.5 rounded cursor-pointer" className="text-muted hover:text-strong p-0.5 rounded cursor-pointer"
> >
<X size={12} /> <X size={12} />
@ -419,9 +479,10 @@ export function MultiSelect({
return ( return (
<div <div
key={opt.value} key={opt.value}
onClick={() => toggleOption(opt.value)} onClick={(e) => toggleOption(opt.value, e)}
title={opt.label}
className={cn( className={cn(
"px-3 py-2 text-xs flex items-center gap-2.5 cursor-pointer transition-colors select-none", "px-3 py-2 text-xs flex items-start gap-2.5 cursor-pointer transition-colors select-none",
isSelected ? "bg-blue-50/70 text-blue-800 font-semibold" : "hover:bg-black/5 text-strong" isSelected ? "bg-blue-50/70 text-blue-800 font-semibold" : "hover:bg-black/5 text-strong"
)} )}
> >
@ -429,9 +490,9 @@ export function MultiSelect({
type="checkbox" type="checkbox"
checked={isSelected} checked={isSelected}
onChange={() => {}} onChange={() => {}}
className="rounded border-slate-300 text-blue-600 focus:ring-blue-500 pointer-events-none h-3.5 w-3.5" className="rounded border-slate-300 text-blue-600 focus:ring-blue-500 pointer-events-none h-3.5 w-3.5 mt-0.5 shrink-0"
/> />
<span className="truncate flex-1">{opt.label}</span> <span className="flex-1 break-words text-xs leading-normal">{opt.label}</span>
</div> </div>
); );
}) })