used layout effect for the select
This commit is contained in:
parent
b6f2872703
commit
f9ea463ea8
@ -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>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user