tududi/frontend/components/Shared/ProjectDropdown.tsx
Chris fcaf9a9a4d
Fix bug 613 (#638)
* Fix missing project meta

* fixup! Fix missing project meta

* fixup! fixup! Fix missing project meta

* fixup! fixup! fixup! Fix missing project meta
2025-12-03 12:58:24 +02:00

305 lines
14 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Project } from '../../entities/Project';
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline';
interface ProjectDropdownProps {
projectName: string;
onProjectSearch: (query: string) => void;
dropdownOpen: boolean;
filteredProjects: Project[];
onProjectSelection: (project: Project) => void;
onCreateProject: (name: string) => void | Promise<void>;
isCreatingProject: boolean;
onShowAllProjects: () => void;
allProjects: Project[];
placeholder?: string;
disabled?: boolean;
selectedProject?: Project | null;
onClearProject?: () => void;
}
const ProjectDropdown: React.FC<ProjectDropdownProps> = ({
projectName,
onProjectSearch,
dropdownOpen,
filteredProjects,
onProjectSelection,
onCreateProject,
isCreatingProject,
onShowAllProjects,
allProjects,
placeholder,
disabled = false,
selectedProject,
onClearProject,
}) => {
const { t } = useTranslation();
const dropdownRef = useRef<HTMLDivElement>(null);
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
// Scroll to dropdown when it opens, keeping input visible
useEffect(() => {
if (dropdownOpen && dropdownRef.current) {
// Small delay to ensure the dropdown is rendered
setTimeout(() => {
const dropdownElement = dropdownRef.current;
if (dropdownElement) {
// Find the input field to keep it visible
const inputElement =
dropdownElement.parentElement?.querySelector('input');
// Find the appropriate scroll container (modal or window)
const modalScrollContainer = dropdownElement.closest(
'.absolute.inset-0.overflow-y-auto'
);
if (modalScrollContainer && inputElement) {
// We're inside a modal - scroll the modal container
const containerRect =
modalScrollContainer.getBoundingClientRect();
const dropdownRect =
dropdownElement.getBoundingClientRect();
const inputRect = inputElement.getBoundingClientRect();
// Only scroll if dropdown extends below the visible area
if (dropdownRect.bottom > containerRect.bottom - 50) {
// Calculate very conservative scroll - prioritize keeping input visible
const inputDistanceFromTop =
inputRect.top - containerRect.top;
const minInputVisible = 60; // Keep at least 60px of input visible
// Only scroll if we can maintain input visibility
if (inputDistanceFromTop > minInputVisible) {
const maxAllowedScroll =
inputDistanceFromTop - minInputVisible;
const neededScroll =
dropdownRect.bottom -
containerRect.bottom +
30;
const scrollAmount = Math.min(
maxAllowedScroll,
neededScroll
);
if (scrollAmount > 0) {
modalScrollContainer.scrollBy({
top: scrollAmount,
behavior: 'smooth',
});
}
}
}
} else if (inputElement) {
// We're not in a modal - scroll the window
const dropdownRect =
dropdownElement.getBoundingClientRect();
const inputRect = inputElement.getBoundingClientRect();
const viewportHeight = window.innerHeight;
// Only scroll if dropdown extends below the viewport
if (dropdownRect.bottom > viewportHeight - 50) {
// Calculate very conservative scroll - prioritize keeping input visible
const inputDistanceFromTop = inputRect.top;
const minInputVisible = 60; // Keep at least 60px of input visible
// Only scroll if we can maintain input visibility
if (inputDistanceFromTop > minInputVisible) {
const maxAllowedScroll =
inputDistanceFromTop - minInputVisible;
const neededScroll =
dropdownRect.bottom - viewportHeight + 30;
const scrollAmount = Math.min(
maxAllowedScroll,
neededScroll
);
if (scrollAmount > 0) {
window.scrollBy({
top: scrollAmount,
behavior: 'smooth',
});
}
}
}
}
}
}, 200);
}
}, [dropdownOpen]);
// Reset highlighted index when dropdown state changes or projects change
useEffect(() => {
setHighlightedIndex(-1);
}, [dropdownOpen, filteredProjects, allProjects]);
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (!dropdownOpen) return;
const projectsToShow = projectName ? filteredProjects : allProjects;
if (event.key === 'ArrowDown') {
event.preventDefault();
setHighlightedIndex((prev) =>
prev < projectsToShow.length - 1 ? prev + 1 : prev
);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : prev));
} else if (event.key === 'Enter') {
event.preventDefault();
// Check for exact match first (case-insensitive)
const exactMatch = projectsToShow.find(
(project) =>
project.name.toLowerCase() ===
projectName.trim().toLowerCase()
);
if (exactMatch) {
// Exact match found - auto-select it
onProjectSelection(exactMatch);
} else if (
highlightedIndex >= 0 &&
highlightedIndex < projectsToShow.length
) {
// Select the highlighted project (after using arrow keys)
onProjectSelection(projectsToShow[highlightedIndex]);
} else if (projectName.trim() && projectsToShow.length === 0) {
// No matches - create new project
onCreateProject(projectName.trim());
}
// Note: Enter does nothing if no exact match and user hasn't navigated with arrows
} else if (event.key === 'Escape') {
event.preventDefault();
setHighlightedIndex(-1);
}
};
return (
<div className="relative">
<div className="flex flex-wrap items-center border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 rounded-md p-2 min-h-[40px]">
{selectedProject ? (
// Only show badge when project is selected - no input allowed
<span className="flex items-center bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 text-sm font-medium px-2.5 py-1 rounded">
{selectedProject.name}
{onClearProject && !disabled && (
<button
type="button"
onClick={onClearProject}
className="ml-1.5 text-blue-600 dark:text-blue-300 hover:text-blue-800 dark:hover:text-blue-100 focus:outline-none"
aria-label={`Remove project ${selectedProject.name}`}
>
&times;
</button>
)}
</span>
) : (
// Only show input when no project is selected
<div className="flex-grow relative min-w-[150px]">
<input
type="text"
placeholder={
placeholder ||
t(
'forms.task.projectSearchPlaceholder',
'Search or create a project...'
)
}
value={projectName}
onChange={(e) => onProjectSearch(e.target.value)}
onKeyDown={handleKeyDown}
disabled={disabled}
className="w-full bg-transparent border-none outline-none text-sm text-gray-900 dark:text-gray-100 disabled:cursor-not-allowed pr-8"
autoComplete="off"
/>
<button
type="button"
onClick={onShowAllProjects}
disabled={disabled}
className="absolute inset-y-0 right-0 flex items-center pr-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
aria-label={
dropdownOpen
? 'Hide projects'
: 'Show all projects'
}
>
{dropdownOpen ? (
<ChevronUpIcon className="h-5 w-5" />
) : (
<ChevronDownIcon className="h-5 w-5" />
)}
</button>
</div>
)}
</div>
{dropdownOpen && !selectedProject && (
<div
ref={dropdownRef}
className="absolute mt-1 bg-white dark:bg-gray-800 shadow-lg rounded-md w-full z-50 border border-gray-200 dark:border-gray-700 max-h-80 overflow-y-auto"
>
{(() => {
// Show filtered projects if user is typing, otherwise show all projects
const projectsToShow = projectName
? filteredProjects
: allProjects;
return projectsToShow.length > 0 ? (
projectsToShow.map((project, index) => (
<button
key={project.id}
type="button"
onClick={() => onProjectSelection(project)}
className={`flex items-center gap-3 w-full text-gray-700 dark:text-gray-300 text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${
index === highlightedIndex
? 'bg-blue-50 dark:bg-blue-900/30'
: ''
}`}
>
<div className="w-10 h-10 rounded overflow-hidden flex-shrink-0">
{project.image_url ? (
<img
src={project.image_url}
alt={project.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-gradient-to-br from-blue-500 to-purple-600 dark:from-blue-600 dark:to-purple-700"></div>
)}
</div>
<span className="flex-1 truncate">
{project.name}
</span>
</button>
))
) : (
<div className="px-4 py-2 text-gray-500 dark:text-gray-400">
{projectName
? t(
'forms.task.noMatchingProjects',
'No matching projects'
)
: 'No projects available'}
</div>
);
})()}
{projectName && (
<button
type="button"
onClick={() => onCreateProject(projectName.trim())}
disabled={isCreatingProject}
className="block w-full text-left px-4 py-2 bg-blue-500 text-white hover:bg-blue-600 transition-colors disabled:bg-blue-400 disabled:cursor-not-allowed"
>
{isCreatingProject
? t('forms.task.creatingProject', 'Creating...')
: t('forms.task.createProject', '+ Create') +
` "${projectName}"`}
</button>
)}
</div>
)}
</div>
);
};
export default ProjectDropdown;