kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(web): add relations to task detail, animated subtasks, and keyboard nav

- Redesign relations with command palette for linking tasks
- Add relation rows matching subtask style (status/assignee popovers, context menu)
- Add animated list transitions for subtasks with framer-motion
- Add keyboard navigation (arrow keys, Space to select, Enter to open, Escape)
- Add focused row indicator with ring style
- Make Properties and Labels headings more readable in sidebar

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Andrej 752bab02 e73fcb33

+508 -270
+74 -60
apps/web/src/components/task/subtask-row.tsx
··· 1 + import { motion } from "framer-motion"; 1 2 import TaskCardContextMenuContent from "@/components/kanban-board/task-card-context-menu/task-card-context-menu-content"; 2 3 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 3 4 import { Checkbox } from "@/components/ui/checkbox"; ··· 13 14 projectId: string; 14 15 workspaceId: string; 15 16 isSelected: boolean; 17 + isFocused: boolean; 16 18 selectionRadius: string; 17 19 assignee: { 18 20 user?: { image?: string | null; name?: string | null } | null; ··· 28 30 projectId, 29 31 workspaceId, 30 32 isSelected, 33 + isFocused, 31 34 selectionRadius, 32 35 assignee, 33 36 onToggleSelection, ··· 35 38 onDeleteClick, 36 39 }: SubtaskRowProps) { 37 40 return ( 38 - <ContextMenu> 39 - <ContextMenuTrigger asChild> 40 - <div 41 - className={`group flex items-center gap-2 py-1 px-2 ${selectionRadius} transition-colors cursor-default ${isSelected ? "bg-primary/10 hover:bg-primary/15" : "hover:bg-accent/50"}`} 42 - > 43 - <Checkbox checked={isSelected} onCheckedChange={onToggleSelection} /> 41 + <motion.div 42 + layout 43 + initial={{ opacity: 0, height: 0 }} 44 + animate={{ opacity: 1, height: "auto" }} 45 + exit={{ opacity: 0, height: 0 }} 46 + transition={{ duration: 0.2, ease: "easeOut" }} 47 + > 48 + <ContextMenu> 49 + <ContextMenuTrigger asChild> 50 + <div 51 + className={`group flex items-center gap-2 py-1 px-2 ${selectionRadius} transition-colors cursor-default ${isSelected ? "bg-primary/10 hover:bg-primary/15" : "hover:bg-accent/50"} ${isFocused ? "ring-1 ring-inset ring-ring/50" : ""}`} 52 + > 53 + <Checkbox 54 + checked={isSelected} 55 + onCheckedChange={onToggleSelection} 56 + /> 44 57 45 - <SubtaskStatusPopover tasks={tasks} projectId={projectId}> 46 - <button 47 - type="button" 48 - className="shrink-0 flex items-center justify-center rounded p-0.5 transition-colors outline-none [&_svg]:text-muted-foreground hover:[&_svg]:text-foreground" 49 - > 50 - {getColumnIcon(task.status, false)} 51 - </button> 52 - </SubtaskStatusPopover> 58 + <SubtaskStatusPopover tasks={tasks} projectId={projectId}> 59 + <button 60 + type="button" 61 + className="shrink-0 flex items-center justify-center rounded p-0.5 transition-colors outline-none [&_svg]:text-muted-foreground hover:[&_svg]:text-foreground" 62 + > 63 + {getColumnIcon(task.status, false)} 64 + </button> 65 + </SubtaskStatusPopover> 53 66 54 - <button 55 - type="button" 56 - className="flex-1 min-w-0 text-left outline-none" 57 - onClick={onNavigate} 58 - > 59 - <span 60 - className={`text-sm truncate block ${task.status === "done" ? "line-through text-muted-foreground" : "text-foreground/90"}`} 61 - > 62 - {task.title} 63 - </span> 64 - </button> 65 - 66 - <SubtaskAssigneePopover tasks={tasks} workspaceId={workspaceId}> 67 67 <button 68 68 type="button" 69 - className="shrink-0 flex items-center justify-center rounded p-0.5 transition-colors outline-none" 69 + className="flex-1 min-w-0 text-left outline-none" 70 + onClick={onNavigate} 70 71 > 71 - {task.userId && assignee ? ( 72 - <Avatar className="h-5 w-5"> 73 - <AvatarImage 74 - src={assignee?.user?.image ?? ""} 75 - alt={assignee?.user?.name || ""} 76 - /> 77 - <AvatarFallback className="text-[9px] font-medium border border-border/30"> 78 - {assignee?.user?.name?.charAt(0).toUpperCase()} 79 - </AvatarFallback> 80 - </Avatar> 81 - ) : ( 82 - <div 83 - className="flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-border/70" 84 - title="Unassigned" 85 - > 86 - <span className="text-[9px] font-medium text-muted-foreground"> 87 - ? 88 - </span> 89 - </div> 90 - )} 72 + <span 73 + className={`text-sm truncate block ${task.status === "done" ? "line-through text-muted-foreground" : "text-foreground/90"}`} 74 + > 75 + {task.title} 76 + </span> 91 77 </button> 92 - </SubtaskAssigneePopover> 93 - </div> 94 - </ContextMenuTrigger> 78 + 79 + <SubtaskAssigneePopover tasks={tasks} workspaceId={workspaceId}> 80 + <button 81 + type="button" 82 + className="shrink-0 flex items-center justify-center rounded p-0.5 transition-colors outline-none" 83 + > 84 + {task.userId && assignee ? ( 85 + <Avatar className="h-5 w-5"> 86 + <AvatarImage 87 + src={assignee?.user?.image ?? ""} 88 + alt={assignee?.user?.name || ""} 89 + /> 90 + <AvatarFallback className="text-[9px] font-medium border border-border/30"> 91 + {assignee?.user?.name?.charAt(0).toUpperCase()} 92 + </AvatarFallback> 93 + </Avatar> 94 + ) : ( 95 + <div 96 + className="flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-border/70" 97 + title="Unassigned" 98 + > 99 + <span className="text-[9px] font-medium text-muted-foreground"> 100 + ? 101 + </span> 102 + </div> 103 + )} 104 + </button> 105 + </SubtaskAssigneePopover> 106 + </div> 107 + </ContextMenuTrigger> 95 108 96 - <TaskCardContextMenuContent 97 - task={task} 98 - taskCardContext={{ 99 - projectId, 100 - worskpaceId: workspaceId, 101 - }} 102 - onDeleteClick={onDeleteClick} 103 - /> 104 - </ContextMenu> 109 + <TaskCardContextMenuContent 110 + task={task} 111 + taskCardContext={{ 112 + projectId, 113 + worskpaceId: workspaceId, 114 + }} 115 + onDeleteClick={onDeleteClick} 116 + /> 117 + </ContextMenu> 118 + </motion.div> 105 119 ); 106 120 }
+8
apps/web/src/components/task/task-details-content.tsx
··· 13 13 import useGetTaskRelations from "@/hooks/queries/task-relation/use-get-task-relations"; 14 14 import type { ExternalLink } from "@/types/external-link"; 15 15 import TaskDescription from "./task-description"; 16 + import TaskRelations from "./task-relations"; 16 17 import TaskSubtasks from "./task-subtasks"; 17 18 import TaskTitle from "./task-title"; 18 19 ··· 85 86 )} 86 87 <div className="mt-4"> 87 88 <TaskSubtasks 89 + taskId={taskId} 90 + projectId={projectId} 91 + workspaceId={workspaceId} 92 + /> 93 + </div> 94 + <div className="mt-2"> 95 + <TaskRelations 88 96 taskId={taskId} 89 97 projectId={projectId} 90 98 workspaceId={workspaceId}
+2 -2
apps/web/src/components/task/task-properties-sidebar.tsx
··· 424 424 {/* Desktop: Title + stacked properties */} 425 425 <div className="hidden lg:block"> 426 426 <div className="flex items-center justify-between px-3 py-2 border-b border-border lg:border-none"> 427 - <p className="text-sm font-medium text-muted-foreground flex-1"> 427 + <p className="text-sm font-medium text-foreground/70 flex-1"> 428 428 Properties 429 429 </p> 430 430 <div className="flex gap-2"> ··· 583 583 584 584 <div className="hidden lg:flex px-3 flex-col gap-3 p-2"> 585 585 <div className="flex flex-col gap-1"> 586 - <span className="text-xs font-medium text-muted-foreground px-2"> 586 + <span className="text-xs font-medium text-foreground/70 px-2"> 587 587 Labels 588 588 </span> 589 589 <div className="flex flex-wrap items-center gap-1.5 px-2">
+294 -176
apps/web/src/components/task/task-relations.tsx
··· 7 7 Search, 8 8 X, 9 9 } from "lucide-react"; 10 - import { useMemo, useState } from "react"; 10 + import { Fragment, useEffect, useMemo, useState } from "react"; 11 + import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 11 12 import { Button } from "@/components/ui/button"; 12 13 import { 13 14 Collapsible, ··· 15 16 CollapsibleTrigger, 16 17 } from "@/components/ui/collapsible"; 17 18 import { 18 - Dialog, 19 - DialogClose, 20 - DialogDescription, 21 - DialogFooter, 22 - DialogHeader, 23 - DialogPopup, 24 - DialogTitle, 25 - DialogTrigger, 26 - } from "@/components/ui/dialog"; 27 - import { Input } from "@/components/ui/input"; 19 + Command, 20 + CommandCollection, 21 + CommandDialog, 22 + CommandDialogPopup, 23 + CommandEmpty, 24 + CommandFooter, 25 + CommandGroup, 26 + CommandGroupLabel, 27 + CommandInput, 28 + CommandItem, 29 + CommandList, 30 + CommandPanel, 31 + CommandSeparator, 32 + } from "@/components/ui/command"; 33 + import { 34 + ContextMenu, 35 + ContextMenuContent, 36 + ContextMenuItem, 37 + ContextMenuSeparator, 38 + ContextMenuTrigger, 39 + } from "@/components/ui/context-menu"; 28 40 import useCreateTaskRelation from "@/hooks/mutations/task-relation/use-create-task-relation"; 29 41 import useDeleteTaskRelation from "@/hooks/mutations/task-relation/use-delete-task-relation"; 42 + import useGetProject from "@/hooks/queries/project/use-get-project"; 30 43 import { useGetTasks } from "@/hooks/queries/task/use-get-tasks"; 31 44 import useGetTaskRelations from "@/hooks/queries/task-relation/use-get-task-relations"; 45 + import useActiveWorkspace from "@/hooks/queries/workspace/use-active-workspace"; 46 + import { useGetActiveWorkspaceUsers } from "@/hooks/queries/workspace-users/use-get-active-workspace-users"; 32 47 import { getColumnIcon } from "@/lib/column"; 33 48 import { toast } from "@/lib/toast"; 49 + import type Task from "@/types/task"; 50 + import SubtaskAssigneePopover from "./subtask-assignee-popover"; 51 + import SubtaskStatusPopover from "./subtask-status-popover"; 34 52 35 53 type TaskRelationsProps = { 36 54 taskId: string; ··· 39 57 }; 40 58 41 59 const relationTypeLabels: Record<string, string> = { 42 - blocks: "Blocks", 43 - related: "Related to", 60 + blocks: "blocks", 61 + related: "relates to", 62 + }; 63 + 64 + type TaskItem = { 65 + id: string; 66 + title: string; 67 + number: number | null; 68 + status: string; 69 + }; 70 + 71 + type TaskGroup = { 72 + value: string; 73 + label: string; 74 + items: TaskItem[]; 44 75 }; 45 76 46 77 export default function TaskRelations({ ··· 50 81 }: TaskRelationsProps) { 51 82 const navigate = useNavigate(); 52 83 const [isOpen, setIsOpen] = useState(true); 53 - const [dialogOpen, setDialogOpen] = useState(false); 84 + const [commandOpen, setCommandOpen] = useState(false); 54 85 const [searchQuery, setSearchQuery] = useState(""); 55 86 const [selectedRelationType, setSelectedRelationType] = useState< 56 87 "blocks" | "related" ··· 58 89 59 90 const { data: relations = [] } = useGetTaskRelations(taskId); 60 91 const { data: projectData } = useGetTasks(projectId); 92 + const { data: project } = useGetProject({ id: projectId, workspaceId }); 93 + const { data: workspace } = useActiveWorkspace(); 94 + const { data: workspaceUsers } = useGetActiveWorkspaceUsers( 95 + workspace?.id ?? "", 96 + ); 61 97 const createRelation = useCreateTaskRelation(); 62 98 const deleteRelation = useDeleteTaskRelation(taskId); 63 99 100 + useEffect(() => { 101 + if (!commandOpen) { 102 + setSearchQuery(""); 103 + } 104 + }, [commandOpen]); 105 + 64 106 const nonSubtaskRelations = relations.filter( 65 107 (rel) => rel.relationType !== "subtask", 66 108 ); ··· 72 114 id: string; 73 115 relationType: string; 74 116 task: NonNullable<(typeof nonSubtaskRelations)[number]["sourceTask"]>; 75 - direction: "source" | "target"; 76 117 }> 77 118 > = {}; 78 119 ··· 89 130 id: rel.id, 90 131 relationType: type, 91 132 task: linkedTask, 92 - direction: isSource ? "source" : "target", 93 133 }); 94 134 } 95 135 ··· 103 143 104 144 const allTasks = useMemo(() => { 105 145 if (!projectData) return []; 106 - const tasks: Array<{ 107 - id: string; 108 - title: string; 109 - number: number | null; 110 - status: string; 111 - }> = []; 146 + const tasks: TaskItem[] = []; 112 147 113 148 if ("columns" in projectData && Array.isArray(projectData.columns)) { 114 149 for (const col of projectData.columns as Array<{ 115 - tasks: Array<{ 116 - id: string; 117 - title: string; 118 - number: number | null; 119 - status: string; 120 - }>; 150 + tasks: TaskItem[]; 121 151 }>) { 122 152 if (col.tasks) { 123 153 for (const t of col.tasks) { ··· 131 161 }, [projectData]); 132 162 133 163 const filteredTasks = allTasks.filter( 134 - (t) => 135 - !existingRelatedTaskIds.has(t.id) && 136 - t.title.toLowerCase().includes(searchQuery.toLowerCase()), 164 + (t) => !existingRelatedTaskIds.has(t.id), 137 165 ); 138 166 167 + const commandGroups = useMemo<TaskGroup[]>(() => { 168 + return [ 169 + { 170 + value: "tasks", 171 + label: "Tasks in project", 172 + items: filteredTasks, 173 + }, 174 + ]; 175 + }, [filteredTasks]); 176 + 139 177 const handleLinkTask = async (targetTaskId: string) => { 140 178 try { 141 179 await createRelation.mutateAsync({ ··· 143 181 targetTaskId, 144 182 relationType: selectedRelationType, 145 183 }); 146 - setDialogOpen(false); 184 + setCommandOpen(false); 147 185 setSearchQuery(""); 148 186 } catch { 149 187 toast.error("Failed to link task"); ··· 161 199 }); 162 200 }; 163 201 164 - const hasRelations = nonSubtaskRelations.length > 0; 202 + const getAssignee = (userId: string | null) => { 203 + if (!userId || !workspaceUsers?.members) return null; 204 + return workspaceUsers.members.find((member) => member.userId === userId); 205 + }; 206 + 207 + const buildTaskObject = (item: { 208 + task: NonNullable<(typeof nonSubtaskRelations)[number]["sourceTask"]>; 209 + }): Task => ({ 210 + id: item.task.id, 211 + title: item.task.title, 212 + number: item.task.number, 213 + description: null, 214 + status: item.task.status, 215 + priority: item.task.priority, 216 + dueDate: null, 217 + position: null, 218 + createdAt: "", 219 + userId: item.task.userId, 220 + assigneeId: item.task.userId, 221 + assigneeName: item.task.assigneeName, 222 + projectId: item.task.projectId, 223 + }); 224 + 225 + const totalCount = nonSubtaskRelations.length; 165 226 166 227 return ( 167 - <Collapsible open={isOpen} onOpenChange={setIsOpen} className="w-full"> 168 - <div className="flex items-center justify-between"> 169 - <CollapsibleTrigger asChild> 228 + <> 229 + <Collapsible open={isOpen} onOpenChange={setIsOpen} className="w-full"> 230 + <div className="flex items-center justify-between"> 231 + <div className="flex items-center gap-1.5"> 232 + <CollapsibleTrigger asChild> 233 + <button 234 + type="button" 235 + className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors" 236 + > 237 + {isOpen ? ( 238 + <ChevronDown className="size-4" /> 239 + ) : ( 240 + <ChevronRight className="size-4" /> 241 + )} 242 + <span>Relations</span> 243 + </button> 244 + </CollapsibleTrigger> 245 + {totalCount > 0 && ( 246 + <span className="text-xs text-muted-foreground"> 247 + {totalCount} 248 + </span> 249 + )} 250 + </div> 170 251 <Button 171 252 variant="ghost" 172 - size="sm" 173 - className="w-full justify-start gap-1 px-0 h-8 hover:bg-transparent" 253 + size="xs" 254 + className="text-muted-foreground" 255 + onClick={() => setCommandOpen(true)} 174 256 > 175 - {isOpen ? ( 176 - <ChevronDown className="size-4 text-muted-foreground" /> 177 - ) : ( 178 - <ChevronRight className="size-4 text-muted-foreground" /> 179 - )} 180 - <Link2 className="size-4 text-muted-foreground" /> 181 - <span className="text-sm text-muted-foreground">Relations</span> 182 - {hasRelations && ( 183 - <span className="text-xs text-muted-foreground ml-1"> 184 - {nonSubtaskRelations.length} 257 + <Plus className="size-3.5" /> 258 + </Button> 259 + </div> 260 + 261 + <CollapsibleContent> 262 + {Object.entries(groupedRelations).map(([type, items]) => ( 263 + <div key={type} className="mt-1.5"> 264 + <span className="text-[11px] text-muted-foreground/70 px-2"> 265 + {relationTypeLabels[type] || type} 185 266 </span> 186 - )} 187 - </Button> 188 - </CollapsibleTrigger> 189 - <Dialog open={dialogOpen} onOpenChange={setDialogOpen}> 190 - <DialogTrigger asChild> 191 - <Button variant="ghost" size="xs" className="text-muted-foreground"> 192 - <Plus className="size-3.5" /> 193 - </Button> 194 - </DialogTrigger> 195 - <DialogPopup className="max-w-md"> 196 - <DialogHeader> 197 - <DialogTitle>Link task</DialogTitle> 198 - <DialogDescription> 199 - Search for a task in this project to create a relation. 200 - </DialogDescription> 201 - </DialogHeader> 202 - <div className="px-6 pb-4 flex flex-col gap-3"> 203 - <div className="flex gap-2"> 204 - <Button 205 - variant={ 206 - selectedRelationType === "related" ? "default" : "outline" 207 - } 208 - size="xs" 267 + <div className="flex flex-col mt-0.5"> 268 + {items.map((item) => { 269 + const assignee = getAssignee(item.task.userId); 270 + const taskObj = buildTaskObject(item); 271 + 272 + return ( 273 + <ContextMenu key={item.id}> 274 + <ContextMenuTrigger asChild> 275 + <div className="group flex items-center gap-2 py-1 px-2 rounded-md hover:bg-accent/50 transition-colors cursor-default"> 276 + <SubtaskStatusPopover 277 + tasks={[taskObj]} 278 + projectId={projectId} 279 + > 280 + <button 281 + type="button" 282 + className="shrink-0 flex items-center justify-center rounded p-0.5 transition-colors outline-none [&_svg]:text-muted-foreground hover:[&_svg]:text-foreground" 283 + > 284 + {getColumnIcon(item.task.status, false)} 285 + </button> 286 + </SubtaskStatusPopover> 287 + 288 + <button 289 + type="button" 290 + className="flex-1 min-w-0 text-left outline-none" 291 + onClick={() => handleNavigateToTask(item.task.id)} 292 + > 293 + <span 294 + className={`text-sm truncate block ${item.task.status === "done" ? "line-through text-muted-foreground" : "text-foreground/90"}`} 295 + > 296 + {item.task.title} 297 + </span> 298 + </button> 299 + 300 + <SubtaskAssigneePopover 301 + tasks={[taskObj]} 302 + workspaceId={workspaceId} 303 + > 304 + <button 305 + type="button" 306 + className="shrink-0 flex items-center justify-center rounded p-0.5 transition-colors outline-none" 307 + > 308 + {item.task.userId && assignee ? ( 309 + <Avatar className="h-5 w-5"> 310 + <AvatarImage 311 + src={assignee?.user?.image ?? ""} 312 + alt={assignee?.user?.name || ""} 313 + /> 314 + <AvatarFallback className="text-[9px] font-medium border border-border/30"> 315 + {assignee?.user?.name 316 + ?.charAt(0) 317 + .toUpperCase()} 318 + </AvatarFallback> 319 + </Avatar> 320 + ) : ( 321 + <div 322 + className="flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-border/70" 323 + title="Unassigned" 324 + > 325 + <span className="text-[9px] font-medium text-muted-foreground"> 326 + ? 327 + </span> 328 + </div> 329 + )} 330 + </button> 331 + </SubtaskAssigneePopover> 332 + </div> 333 + </ContextMenuTrigger> 334 + 335 + <ContextMenuContent className="w-40"> 336 + <ContextMenuItem 337 + onClick={() => handleNavigateToTask(item.task.id)} 338 + > 339 + <span>Open task</span> 340 + </ContextMenuItem> 341 + <ContextMenuSeparator /> 342 + <ContextMenuItem 343 + className="text-destructive" 344 + onClick={() => handleRemoveRelation(item.id)} 345 + > 346 + <span>Remove relation</span> 347 + </ContextMenuItem> 348 + </ContextMenuContent> 349 + </ContextMenu> 350 + ); 351 + })} 352 + </div> 353 + </div> 354 + ))} 355 + 356 + {totalCount === 0 && ( 357 + <p className="text-xs text-muted-foreground px-2 py-1"> 358 + No related tasks 359 + </p> 360 + )} 361 + </CollapsibleContent> 362 + </Collapsible> 363 + 364 + <CommandDialog open={commandOpen} onOpenChange={setCommandOpen}> 365 + <CommandDialogPopup> 366 + <Command items={commandGroups}> 367 + <CommandInput 368 + placeholder="Search tasks to link..." 369 + value={searchQuery} 370 + onChange={(e) => setSearchQuery(e.target.value)} 371 + /> 372 + <CommandPanel> 373 + <CommandEmpty> 374 + <div className="text-center py-6"> 375 + <Search className="h-8 w-8 mx-auto mb-2 text-muted-foreground" /> 376 + <p className="text-sm text-muted-foreground"> 377 + No tasks found 378 + </p> 379 + </div> 380 + </CommandEmpty> 381 + <CommandList> 382 + {(group: TaskGroup, groupIndex: number) => ( 383 + <Fragment key={group.value}> 384 + <CommandGroup items={group.items}> 385 + <CommandGroupLabel>{group.label}</CommandGroupLabel> 386 + <CommandCollection> 387 + {(item: TaskItem) => ( 388 + <CommandItem 389 + key={item.id} 390 + value={`${project?.slug}-${item.number} ${item.title}`} 391 + onClick={() => handleLinkTask(item.id)} 392 + className="flex items-center gap-3 py-2" 393 + > 394 + {getColumnIcon(item.status, false)} 395 + <span className="text-xs text-muted-foreground shrink-0 font-mono"> 396 + {project?.slug}-{item.number} 397 + </span> 398 + <span className="text-sm truncate flex-1"> 399 + {item.title} 400 + </span> 401 + </CommandItem> 402 + )} 403 + </CommandCollection> 404 + </CommandGroup> 405 + {groupIndex < commandGroups.length - 1 && ( 406 + <CommandSeparator /> 407 + )} 408 + </Fragment> 409 + )} 410 + </CommandList> 411 + </CommandPanel> 412 + <CommandFooter> 413 + <div className="flex items-center gap-3"> 414 + <button 415 + type="button" 416 + className={`flex items-center gap-1.5 text-xs px-2 py-1 rounded-md transition-colors ${selectedRelationType === "related" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"}`} 209 417 onClick={() => setSelectedRelationType("related")} 210 418 > 419 + <Link2 className="size-3" /> 211 420 Related 212 - </Button> 213 - <Button 214 - variant={ 215 - selectedRelationType === "blocks" ? "default" : "outline" 216 - } 217 - size="xs" 421 + </button> 422 + <button 423 + type="button" 424 + className={`flex items-center gap-1.5 text-xs px-2 py-1 rounded-md transition-colors ${selectedRelationType === "blocks" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"}`} 218 425 onClick={() => setSelectedRelationType("blocks")} 219 426 > 427 + <X className="size-3" /> 220 428 Blocks 221 - </Button> 429 + </button> 222 430 </div> 223 - <div className="relative"> 224 - <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" /> 225 - <Input 226 - size="sm" 227 - placeholder="Search tasks..." 228 - value={searchQuery} 229 - onChange={(e: React.ChangeEvent<HTMLInputElement>) => 230 - setSearchQuery(e.target.value) 231 - } 232 - className="pl-8" 233 - autoFocus 234 - /> 235 - </div> 236 - <div className="max-h-60 overflow-y-auto flex flex-col gap-0.5"> 237 - {filteredTasks.length === 0 && ( 238 - <p className="text-sm text-muted-foreground py-4 text-center"> 239 - No tasks found 240 - </p> 241 - )} 242 - {filteredTasks.map((t) => ( 243 - <button 244 - key={t.id} 245 - type="button" 246 - className="flex items-center gap-2 py-2 px-2 rounded-md hover:bg-accent/50 transition-colors text-left w-full" 247 - onClick={() => handleLinkTask(t.id)} 248 - > 249 - {getColumnIcon(t.status, false)} 250 - <span className="text-sm truncate flex-1 text-foreground/90"> 251 - {t.title} 252 - </span> 253 - {t.number != null && ( 254 - <span className="text-xs text-muted-foreground shrink-0"> 255 - #{t.number} 256 - </span> 257 - )} 258 - </button> 259 - ))} 260 - </div> 261 - </div> 262 - <DialogFooter variant="bare"> 263 - <DialogClose asChild> 264 - <Button variant="outline" size="sm"> 265 - Cancel 266 - </Button> 267 - </DialogClose> 268 - </DialogFooter> 269 - </DialogPopup> 270 - </Dialog> 271 - </div> 272 - 273 - <CollapsibleContent> 274 - {Object.entries(groupedRelations).map(([type, items]) => ( 275 - <div key={type} className="mt-2"> 276 - <p className="text-xs font-medium text-muted-foreground px-2 mb-1"> 277 - {relationTypeLabels[type] || type} 278 - </p> 279 - <div className="flex flex-col gap-0.5"> 280 - {items.map((item) => ( 281 - <div 282 - key={item.id} 283 - className="group flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-accent/50 transition-colors" 284 - > 285 - <button 286 - type="button" 287 - className="flex items-center gap-2 flex-1 min-w-0 text-left" 288 - onClick={() => handleNavigateToTask(item.task.id)} 289 - > 290 - {getColumnIcon(item.task.status, false)} 291 - <span className="text-sm truncate flex-1 text-foreground/90"> 292 - {item.task.title} 293 - </span> 294 - {item.task.number != null && ( 295 - <span className="text-xs text-muted-foreground shrink-0"> 296 - #{item.task.number} 297 - </span> 298 - )} 299 - </button> 300 - <Button 301 - variant="ghost" 302 - size="xs" 303 - className="opacity-0 group-hover:opacity-100 text-muted-foreground h-5 w-5 p-0" 304 - onClick={() => handleRemoveRelation(item.id)} 305 - > 306 - <X className="size-3" /> 307 - </Button> 308 - </div> 309 - ))} 310 - </div> 311 - </div> 312 - ))} 313 - 314 - {!hasRelations && ( 315 - <p className="text-xs text-muted-foreground px-2 py-1"> 316 - No related tasks 317 - </p> 318 - )} 319 - </CollapsibleContent> 320 - </Collapsible> 431 + <span className="text-muted-foreground/60"> 432 + Select a task to link 433 + </span> 434 + </CommandFooter> 435 + </Command> 436 + </CommandDialogPopup> 437 + </CommandDialog> 438 + </> 321 439 ); 322 440 }
+130 -32
apps/web/src/components/task/task-subtasks.tsx
··· 1 1 import { useNavigate } from "@tanstack/react-router"; 2 + import { AnimatePresence } from "framer-motion"; 2 3 import { ChevronDown, ChevronRight, Plus } from "lucide-react"; 3 - import { useState } from "react"; 4 + import { useCallback, useEffect, useRef, useState } from "react"; 4 5 import { 5 6 AlertDialog, 6 7 AlertDialogClose, ··· 46 47 const [newTitle, setNewTitle] = useState(""); 47 48 const [deleteTaskId, setDeleteTaskId] = useState<string | null>(null); 48 49 const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); 50 + const [focusedIndex, setFocusedIndex] = useState(-1); 51 + const containerRef = useRef<HTMLDivElement>(null); 49 52 50 53 const { data: relations = [] } = useGetTaskRelations(taskId); 51 54 const { data: workspace } = useActiveWorkspace(); ··· 72 75 const totalCount = subtasks.length; 73 76 const hasSelection = selectedIds.size > 0; 74 77 75 - const toggleSelection = (id: string) => { 78 + const toggleSelection = useCallback((id: string) => { 76 79 setSelectedIds((prev) => { 77 80 const next = new Set(prev); 78 81 if (next.has(id)) { ··· 82 85 } 83 86 return next; 84 87 }); 85 - }; 88 + }, []); 89 + 90 + const clearSelection = useCallback(() => { 91 + setSelectedIds(new Set()); 92 + setFocusedIndex(-1); 93 + }, []); 86 94 87 95 const buildTaskObject = (subtask: (typeof subtasks)[number]): Task => ({ 88 96 id: subtask.task.id, ··· 129 137 return "rounded-md"; 130 138 }; 131 139 140 + // Keyboard navigation 141 + useEffect(() => { 142 + const container = containerRef.current; 143 + if (!container || totalCount === 0) return; 144 + 145 + const handleKeyDown = (e: KeyboardEvent) => { 146 + const target = e.target as HTMLElement | null; 147 + if ( 148 + target?.closest( 149 + "input, textarea, [contenteditable='true'], .ProseMirror", 150 + ) 151 + ) 152 + return; 153 + 154 + if (!container.contains(document.activeElement) && focusedIndex === -1) 155 + return; 156 + 157 + switch (e.key) { 158 + case "ArrowDown": 159 + case "j": { 160 + e.preventDefault(); 161 + setFocusedIndex((prev) => (prev < totalCount - 1 ? prev + 1 : prev)); 162 + break; 163 + } 164 + case "ArrowUp": 165 + case "k": { 166 + e.preventDefault(); 167 + setFocusedIndex((prev) => (prev > 0 ? prev - 1 : prev)); 168 + break; 169 + } 170 + case " ": { 171 + if (focusedIndex >= 0 && focusedIndex < totalCount) { 172 + e.preventDefault(); 173 + toggleSelection(subtasks[focusedIndex].task.id); 174 + } 175 + break; 176 + } 177 + case "Enter": { 178 + if (focusedIndex >= 0 && focusedIndex < totalCount) { 179 + e.preventDefault(); 180 + navigate({ 181 + to: "/dashboard/workspace/$workspaceId/project/$projectId/task/$taskId", 182 + params: { 183 + workspaceId, 184 + projectId, 185 + taskId: subtasks[focusedIndex].task.id, 186 + }, 187 + }); 188 + } 189 + break; 190 + } 191 + case "Escape": { 192 + if (hasSelection) { 193 + e.preventDefault(); 194 + clearSelection(); 195 + } else if (focusedIndex >= 0) { 196 + e.preventDefault(); 197 + setFocusedIndex(-1); 198 + } 199 + break; 200 + } 201 + } 202 + }; 203 + 204 + document.addEventListener("keydown", handleKeyDown); 205 + return () => document.removeEventListener("keydown", handleKeyDown); 206 + }, [ 207 + focusedIndex, 208 + totalCount, 209 + subtasks, 210 + hasSelection, 211 + clearSelection, 212 + navigate, 213 + workspaceId, 214 + projectId, 215 + toggleSelection, 216 + ]); 217 + 132 218 const handleAddSubtask = async () => { 133 219 if (!newTitle.trim()) return; 134 220 ··· 216 302 </div> 217 303 218 304 <CollapsibleContent> 219 - <div className="flex flex-col mt-1"> 220 - {subtasks.map((subtask, index) => { 221 - const taskObj = buildTaskObject(subtask); 222 - const isSelected = selectedIds.has(subtask.task.id); 305 + {/* biome-ignore lint/a11y/noStaticElementInteractions: keyboard nav managed via document listener */} 306 + <div 307 + ref={containerRef} 308 + className="flex flex-col mt-1" 309 + onMouseDown={() => { 310 + if (focusedIndex === -1 && !hasSelection) { 311 + setFocusedIndex(0); 312 + } 313 + }} 314 + > 315 + <AnimatePresence initial={false}> 316 + {subtasks.map((subtask, index) => { 317 + const taskObj = buildTaskObject(subtask); 318 + const isSelected = selectedIds.has(subtask.task.id); 223 319 224 - return ( 225 - <SubtaskRow 226 - key={subtask.relation.id} 227 - task={taskObj} 228 - tasks={getTargetTasks(taskObj)} 229 - projectId={projectId} 230 - workspaceId={workspace?.id ?? workspaceId} 231 - isSelected={isSelected} 232 - selectionRadius={getSelectionRadius(index, isSelected)} 233 - assignee={getAssignee(subtask.task.userId)} 234 - onToggleSelection={() => toggleSelection(subtask.task.id)} 235 - onNavigate={() => 236 - navigate({ 237 - to: "/dashboard/workspace/$workspaceId/project/$projectId/task/$taskId", 238 - params: { 239 - workspaceId, 240 - projectId, 241 - taskId: subtask.task.id, 242 - }, 243 - }) 244 - } 245 - onDeleteClick={() => setDeleteTaskId(subtask.task.id)} 246 - /> 247 - ); 248 - })} 320 + return ( 321 + <SubtaskRow 322 + key={subtask.task.id} 323 + task={taskObj} 324 + tasks={getTargetTasks(taskObj)} 325 + projectId={projectId} 326 + workspaceId={workspace?.id ?? workspaceId} 327 + isSelected={isSelected} 328 + isFocused={focusedIndex === index} 329 + selectionRadius={getSelectionRadius(index, isSelected)} 330 + assignee={getAssignee(subtask.task.userId)} 331 + onToggleSelection={() => toggleSelection(subtask.task.id)} 332 + onNavigate={() => 333 + navigate({ 334 + to: "/dashboard/workspace/$workspaceId/project/$projectId/task/$taskId", 335 + params: { 336 + workspaceId, 337 + projectId, 338 + taskId: subtask.task.id, 339 + }, 340 + }) 341 + } 342 + onDeleteClick={() => setDeleteTaskId(subtask.task.id)} 343 + /> 344 + ); 345 + })} 346 + </AnimatePresence> 249 347 </div> 250 348 251 349 {isAdding && (