feat(assignee-selector): enhance dropdown functionality and position handling
- Added button reference and dropdown position state to improve dropdown positioning. - Implemented useCallback for updating dropdown position on scroll and resize events. - Enhanced click outside handling to close the dropdown correctly. - Utilized createPortal for rendering the dropdown, ensuring it overlays correctly in the DOM. - Improved styling and behavior of the dropdown button based on its open state.
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { PlusOutlined, UserAddOutlined } from '@ant-design/icons';
|
import { PlusOutlined, UserAddOutlined } from '@ant-design/icons';
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
@@ -24,7 +25,9 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
|||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [teamMembers, setTeamMembers] = useState<ITeamMembersViewModel>({ data: [], total: 0 });
|
const [teamMembers, setTeamMembers] = useState<ITeamMembersViewModel>({ data: [], total: 0 });
|
||||||
|
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const { projectId } = useSelector((state: RootState) => state.projectReducer);
|
const { projectId } = useSelector((state: RootState) => state.projectReducer);
|
||||||
@@ -38,20 +41,61 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
|||||||
);
|
);
|
||||||
}, [teamMembers, searchQuery]);
|
}, [teamMembers, searchQuery]);
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
// Update dropdown position
|
||||||
|
const updateDropdownPosition = useCallback(() => {
|
||||||
|
if (buttonRef.current) {
|
||||||
|
const rect = buttonRef.current.getBoundingClientRect();
|
||||||
|
setDropdownPosition({
|
||||||
|
top: rect.bottom + window.scrollY + 2,
|
||||||
|
left: rect.left + window.scrollX,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside and handle scroll
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node) &&
|
||||||
|
buttonRef.current && !buttonRef.current.contains(event.target as Node)) {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
const handleScroll = () => {
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
if (isOpen) {
|
||||||
}, []);
|
updateDropdownPosition();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDropdownToggle = () => {
|
const handleResize = () => {
|
||||||
|
if (isOpen) {
|
||||||
|
updateDropdownPosition();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
window.addEventListener('scroll', handleScroll, true);
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
window.removeEventListener('scroll', handleScroll, true);
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
}, [isOpen, updateDropdownPosition]);
|
||||||
|
|
||||||
|
const handleDropdownToggle = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
|
updateDropdownPosition();
|
||||||
|
|
||||||
// Prepare team members data when opening
|
// Prepare team members data when opening
|
||||||
const assignees = task?.assignees?.map(assignee => assignee.team_member_id);
|
const assignees = task?.assignees?.map(assignee => assignee.team_member_id);
|
||||||
const membersData = (members?.data || []).map(member => ({
|
const membersData = (members?.data || []).map(member => ({
|
||||||
@@ -61,12 +105,14 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
|||||||
const sortedMembers = sortTeamMembers(membersData);
|
const sortedMembers = sortTeamMembers(membersData);
|
||||||
setTeamMembers({ data: sortedMembers });
|
setTeamMembers({ data: sortedMembers });
|
||||||
|
|
||||||
|
setIsOpen(true);
|
||||||
// Focus search input after opening
|
// Focus search input after opening
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
searchInputRef.current?.focus();
|
searchInputRef.current?.focus();
|
||||||
}, 0);
|
}, 0);
|
||||||
|
} else {
|
||||||
|
setIsOpen(false);
|
||||||
}
|
}
|
||||||
setIsOpen(!isOpen);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMemberToggle = (memberId: string, checked: boolean) => {
|
const handleMemberToggle = (memberId: string, checked: boolean) => {
|
||||||
@@ -91,30 +137,40 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={dropdownRef}>
|
<>
|
||||||
<button
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
onClick={handleDropdownToggle}
|
onClick={handleDropdownToggle}
|
||||||
className={`
|
className={`
|
||||||
w-5 h-5 rounded-full border border-dashed flex items-center justify-center
|
w-5 h-5 rounded-full border border-dashed flex items-center justify-center
|
||||||
transition-colors duration-200
|
transition-colors duration-200
|
||||||
${isDarkMode
|
${isOpen
|
||||||
? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400'
|
? isDarkMode
|
||||||
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600'
|
? 'border-blue-500 bg-blue-900/20 text-blue-400'
|
||||||
|
: 'border-blue-500 bg-blue-50 text-blue-600'
|
||||||
|
: isDarkMode
|
||||||
|
? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400'
|
||||||
|
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<PlusOutlined className="text-xs" />
|
<PlusOutlined className="text-xs" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && createPortal(
|
||||||
<div
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
className={`
|
className={`
|
||||||
absolute top-6 left-0 z-50 w-72 rounded-md shadow-lg border
|
fixed z-[9999] w-72 rounded-md shadow-lg border
|
||||||
${isDarkMode
|
${isDarkMode
|
||||||
? 'bg-gray-800 border-gray-600'
|
? 'bg-gray-800 border-gray-600'
|
||||||
: 'bg-white border-gray-200'
|
: 'bg-white border-gray-200'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
|
style={{
|
||||||
|
top: dropdownPosition.top,
|
||||||
|
left: dropdownPosition.left,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className={`p-2 border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`}>
|
<div className={`p-2 border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`}>
|
||||||
@@ -211,9 +267,10 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
|||||||
Invite member
|
Invite member
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useMemo, useCallback, useState, useRef, useEffect } from 'react'
|
|||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
import {
|
import {
|
||||||
Input,
|
Input,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -86,18 +87,24 @@ const TaskKey = React.memo<{ taskKey: string; isDarkMode: boolean }>(({ taskKey,
|
|||||||
</span>
|
</span>
|
||||||
));
|
));
|
||||||
|
|
||||||
const TaskDescription = React.memo<{ description?: string; isDarkMode: boolean }>(({ description, isDarkMode }) => (
|
const TaskDescription = React.memo<{ description?: string; isDarkMode: boolean }>(({ description, isDarkMode }) => {
|
||||||
<Typography.Paragraph
|
if (!description) return null;
|
||||||
ellipsis={{
|
|
||||||
expandable: false,
|
const sanitizedDescription = DOMPurify.sanitize(description);
|
||||||
rows: 1,
|
|
||||||
tooltip: description,
|
return (
|
||||||
}}
|
<Typography.Paragraph
|
||||||
className={`w-full mb-0 text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}
|
ellipsis={{
|
||||||
>
|
expandable: false,
|
||||||
{description || ''}
|
rows: 1,
|
||||||
</Typography.Paragraph>
|
tooltip: description,
|
||||||
));
|
}}
|
||||||
|
className={`w-full mb-0 text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}
|
||||||
|
>
|
||||||
|
<span dangerouslySetInnerHTML={{ __html: sanitizedDescription }} />
|
||||||
|
</Typography.Paragraph>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const TaskProgress = React.memo<{ progress: number; isDarkMode: boolean }>(({ progress, isDarkMode }) => (
|
const TaskProgress = React.memo<{ progress: number; isDarkMode: boolean }>(({ progress, isDarkMode }) => (
|
||||||
<Progress
|
<Progress
|
||||||
@@ -402,8 +409,8 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
|
|
||||||
case 'members':
|
case 'members':
|
||||||
return (
|
return (
|
||||||
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
|
<div key={col.key} className={`flex items-center px-2 ${borderClasses} overflow-visible`} style={{ width: col.width }}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 overflow-visible">
|
||||||
{task.assignee_names && task.assignee_names.length > 0 && (
|
{task.assignee_names && task.assignee_names.length > 0 && (
|
||||||
<AvatarGroup
|
<AvatarGroup
|
||||||
members={task.assignee_names}
|
members={task.assignee_names}
|
||||||
@@ -582,7 +589,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
{/* Fixed Columns */}
|
{/* Fixed Columns */}
|
||||||
{fixedColumns && fixedColumns.length > 0 && (
|
{fixedColumns && fixedColumns.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="flex"
|
className="flex overflow-visible"
|
||||||
style={{
|
style={{
|
||||||
width: fixedColumns.reduce((sum, col) => sum + col.width, 0),
|
width: fixedColumns.reduce((sum, col) => sum + col.width, 0),
|
||||||
}}
|
}}
|
||||||
|
|||||||
Reference in New Issue
Block a user