feat(ProjectSelector): add auto-scroll and improved UX for project picker

This commit is contained in:
Illia Filippov
2025-12-23 17:45:04 +01:00
parent e95912f931
commit 190f18ecae
4 changed files with 90 additions and 28 deletions

View File

@@ -64,6 +64,7 @@ export function ProjectSelectorWithOptions({
setProjectSearchQuery, setProjectSearchQuery,
selectedProjectIndex, selectedProjectIndex,
projectSearchInputRef, projectSearchInputRef,
scrollContainerRef,
filteredProjects, filteredProjects,
} = useProjectPicker({ } = useProjectPicker({
projects, projects,
@@ -171,7 +172,10 @@ export function ProjectSelectorWithOptions({
items={filteredProjects.map((p) => p.id)} items={filteredProjects.map((p) => p.id)}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
<div className="space-y-0.5 max-h-64 overflow-y-auto overflow-x-hidden"> <div
ref={scrollContainerRef}
className="space-y-0.5 max-h-64 overflow-y-auto overflow-x-hidden scroll-smooth project-picker-scroll"
>
{filteredProjects.map((project, index) => ( {filteredProjects.map((project, index) => (
<SortableProjectItem <SortableProjectItem
key={project.id} key={project.id}

View File

@@ -31,6 +31,7 @@ export function SortableProjectItem({
isHighlighted && 'bg-brand-500/10 text-foreground ring-1 ring-brand-500/20' isHighlighted && 'bg-brand-500/10 text-foreground ring-1 ring-brand-500/20'
)} )}
data-testid={`project-option-${project.id}`} data-testid={`project-option-${project.id}`}
onClick={() => onSelect(project)}
> >
{/* Drag Handle */} {/* Drag Handle */}
<button <button
@@ -43,8 +44,8 @@ export function SortableProjectItem({
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/60" /> <GripVertical className="h-3.5 w-3.5 text-muted-foreground/60" />
</button> </button>
{/* Project content - clickable area */} {/* Project content */}
<div className="flex items-center gap-2.5 flex-1 min-w-0" onClick={() => onSelect(project)}> <div className="flex items-center gap-2.5 flex-1 min-w-0">
<Folder <Folder
className={cn( className={cn(
'h-4 w-4 shrink-0', 'h-4 w-4 shrink-0',

View File

@@ -19,6 +19,7 @@ export function useProjectPicker({
const [projectSearchQuery, setProjectSearchQuery] = useState(''); const [projectSearchQuery, setProjectSearchQuery] = useState('');
const [selectedProjectIndex, setSelectedProjectIndex] = useState(0); const [selectedProjectIndex, setSelectedProjectIndex] = useState(0);
const projectSearchInputRef = useRef<HTMLInputElement>(null); const projectSearchInputRef = useRef<HTMLInputElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Filtered projects based on search query // Filtered projects based on search query
const filteredProjects = useMemo(() => { const filteredProjects = useMemo(() => {
@@ -29,38 +30,67 @@ export function useProjectPicker({
return projects.filter((project) => project.name.toLowerCase().includes(query)); return projects.filter((project) => project.name.toLowerCase().includes(query));
}, [projects, projectSearchQuery]); }, [projects, projectSearchQuery]);
// Reset selection when filtered results change and project picker is open // Helper function to scroll to a specific project
useEffect(() => { const scrollToProject = useCallback((projectId: string) => {
if (!isProjectPickerOpen) { if (!scrollContainerRef.current) return;
return;
const element = scrollContainerRef.current.querySelector(
`[data-testid="project-option-${projectId}"]`
) as HTMLElement;
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
} }
}, []);
if (!projectSearchQuery.trim()) { // Initialize state when dropdown opens, reset when it closes
const currentIndex = currentProject
? filteredProjects.findIndex((p) => p.id === currentProject.id)
: -1;
if (currentIndex !== -1) {
setSelectedProjectIndex(currentIndex);
return;
}
}
setSelectedProjectIndex(0);
}, [isProjectPickerOpen, filteredProjects.length, projectSearchQuery, currentProject]);
// Reset search query when dropdown closes, set to current project index when it opens
useEffect(() => { useEffect(() => {
if (!isProjectPickerOpen) { if (!isProjectPickerOpen) {
setProjectSearchQuery(''); setProjectSearchQuery('');
setSelectedProjectIndex(0); setSelectedProjectIndex(0);
} else { return;
// Focus the search input when dropdown opens
// Small delay to ensure the dropdown is rendered
setTimeout(() => {
projectSearchInputRef.current?.focus();
}, 0);
} }
}, [isProjectPickerOpen]);
// When opening, find and select the current project
const currentIndex = currentProject
? filteredProjects.findIndex((p) => p.id === currentProject.id)
: -1;
const initialIndex = currentIndex !== -1 ? currentIndex : 0;
setSelectedProjectIndex(initialIndex);
// Focus search input and scroll to current project after DOM renders
requestAnimationFrame(() => {
projectSearchInputRef.current?.focus();
// Scroll to the current project
const targetProject = filteredProjects[initialIndex];
if (targetProject) {
scrollToProject(targetProject.id);
}
});
}, [isProjectPickerOpen, currentProject?.id]);
// Update selection when search query changes (while picker is open)
useEffect(() => {
if (!isProjectPickerOpen || !projectSearchQuery.trim()) return;
// When searching, reset to first result
setSelectedProjectIndex(0);
}, [isProjectPickerOpen, projectSearchQuery]);
// Scroll to highlighted item when selection changes via keyboard
useEffect(() => {
if (!isProjectPickerOpen) return;
const targetProject = filteredProjects[selectedProjectIndex];
if (targetProject) {
scrollToProject(targetProject.id);
}
}, [selectedProjectIndex, isProjectPickerOpen, filteredProjects, scrollToProject]);
// Handle selecting the currently highlighted project // Handle selecting the currently highlighted project
const selectHighlightedProject = useCallback(() => { const selectHighlightedProject = useCallback(() => {
@@ -111,6 +141,7 @@ export function useProjectPicker({
selectedProjectIndex, selectedProjectIndex,
setSelectedProjectIndex, setSelectedProjectIndex,
projectSearchInputRef, projectSearchInputRef,
scrollContainerRef,
filteredProjects, filteredProjects,
selectHighlightedProject, selectHighlightedProject,
}; };

View File

@@ -870,6 +870,32 @@
animation: accordion-up 0.2s ease-out forwards; animation: accordion-up 0.2s ease-out forwards;
} }
/* Project picker scrollbar styling */
.project-picker-scroll {
scrollbar-width: thin;
scrollbar-color: var(--muted-foreground) transparent;
}
.project-picker-scroll::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.project-picker-scroll::-webkit-scrollbar-track {
background: transparent;
border-radius: 3px;
}
.project-picker-scroll::-webkit-scrollbar-thumb {
background: var(--muted-foreground);
border-radius: 3px;
min-height: 30px;
}
.project-picker-scroll::-webkit-scrollbar-thumb:hover {
background: var(--foreground-secondary);
}
/* Terminal scrollbar theming */ /* Terminal scrollbar theming */
.xterm-viewport::-webkit-scrollbar { .xterm-viewport::-webkit-scrollbar {
width: 8px; width: 8px;