Spaces:
Sleeping
Sleeping
Merge pull request #138 from bhavikprit/feat/40-accessibility
Browse files
src/app/[[...panel]]/page.tsx
CHANGED
|
@@ -140,6 +140,9 @@ export default function Home() {
|
|
| 140 |
|
| 141 |
return (
|
| 142 |
<div className="flex h-screen bg-background overflow-hidden">
|
|
|
|
|
|
|
|
|
|
| 143 |
{/* Left: Icon rail navigation (hidden on mobile, shown as bottom bar instead) */}
|
| 144 |
<NavRail />
|
| 145 |
|
|
@@ -149,7 +152,7 @@ export default function Home() {
|
|
| 149 |
<LocalModeBanner />
|
| 150 |
<UpdateBanner />
|
| 151 |
<PromoBanner />
|
| 152 |
-
<main className="flex-1 overflow-auto pb-16 md:pb-0" role="main">
|
| 153 |
<div aria-live="polite">
|
| 154 |
<ErrorBoundary key={activeTab}>
|
| 155 |
<ContentRouter tab={activeTab} />
|
|
|
|
| 140 |
|
| 141 |
return (
|
| 142 |
<div className="flex h-screen bg-background overflow-hidden">
|
| 143 |
+
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:top-2 focus:left-2 focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-md focus:text-sm focus:font-medium">
|
| 144 |
+
Skip to main content
|
| 145 |
+
</a>
|
| 146 |
{/* Left: Icon rail navigation (hidden on mobile, shown as bottom bar instead) */}
|
| 147 |
<NavRail />
|
| 148 |
|
|
|
|
| 152 |
<LocalModeBanner />
|
| 153 |
<UpdateBanner />
|
| 154 |
<PromoBanner />
|
| 155 |
+
<main id="main-content" className="flex-1 overflow-auto pb-16 md:pb-0" role="main">
|
| 156 |
<div aria-live="polite">
|
| 157 |
<ErrorBoundary key={activeTab}>
|
| 158 |
<ContentRouter tab={activeTab} />
|
src/components/panels/task-board-panel.tsx
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
import { useState, useEffect, useCallback, useRef } from 'react'
|
| 4 |
import { useMissionControl } from '@/store'
|
| 5 |
import { useSmartPoll } from '@/lib/use-smart-poll'
|
|
|
|
| 6 |
import { AgentAvatar } from '@/components/ui/agent-avatar'
|
| 7 |
import { MarkdownRenderer } from '@/components/markdown-renderer'
|
| 8 |
|
|
@@ -268,8 +269,8 @@ export function TaskBoardPanel() {
|
|
| 268 |
|
| 269 |
if (loading) {
|
| 270 |
return (
|
| 271 |
-
<div className="flex items-center justify-center h-64">
|
| 272 |
-
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
| 273 |
<span className="ml-2 text-muted-foreground">Loading tasks...</span>
|
| 274 |
</div>
|
| 275 |
)
|
|
@@ -298,11 +299,12 @@ export function TaskBoardPanel() {
|
|
| 298 |
|
| 299 |
{/* Error Display */}
|
| 300 |
{error && (
|
| 301 |
-
<div className="bg-red-500/10 border border-red-500/20 text-red-400 p-3 m-4 rounded-lg text-sm flex items-center justify-between">
|
| 302 |
<span>{error}</span>
|
| 303 |
<button
|
| 304 |
onClick={() => setError(null)}
|
| 305 |
className="text-red-400/60 hover:text-red-400 ml-2"
|
|
|
|
| 306 |
>
|
| 307 |
×
|
| 308 |
</button>
|
|
@@ -310,10 +312,12 @@ export function TaskBoardPanel() {
|
|
| 310 |
)}
|
| 311 |
|
| 312 |
{/* Kanban Board */}
|
| 313 |
-
<div className="flex-1 flex gap-4 p-4 overflow-x-auto">
|
| 314 |
{statusColumns.map(column => (
|
| 315 |
<div
|
| 316 |
key={column.key}
|
|
|
|
|
|
|
| 317 |
className="flex-1 min-w-80 bg-card border border-border rounded-lg flex flex-col"
|
| 318 |
onDragEnter={(e) => handleDragEnter(e, column.key)}
|
| 319 |
onDragLeave={handleDragLeave}
|
|
@@ -334,8 +338,12 @@ export function TaskBoardPanel() {
|
|
| 334 |
<div
|
| 335 |
key={task.id}
|
| 336 |
draggable
|
|
|
|
|
|
|
|
|
|
| 337 |
onDragStart={(e) => handleDragStart(e, task)}
|
| 338 |
onClick={() => setSelectedTask(task)}
|
|
|
|
| 339 |
className={`bg-surface-1 rounded-lg p-3 cursor-pointer hover:bg-surface-2 transition-smooth border-l-4 ${priorityColors[task.priority]} ${
|
| 340 |
draggedTask?.id === task.id ? 'opacity-50' : ''
|
| 341 |
}`}
|
|
@@ -610,12 +618,14 @@ function TaskDetailModal({
|
|
| 610 |
</div>
|
| 611 |
)
|
| 612 |
|
|
|
|
|
|
|
| 613 |
return (
|
| 614 |
-
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
| 615 |
-
<div className="bg-card border border-border rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
| 616 |
<div className="p-6">
|
| 617 |
<div className="flex justify-between items-start mb-4">
|
| 618 |
-
<h3 className="text-xl font-bold text-foreground">{task.title}</h3>
|
| 619 |
<div className="flex gap-2">
|
| 620 |
<button
|
| 621 |
onClick={() => onEdit(task)}
|
|
@@ -625,6 +635,7 @@ function TaskDetailModal({
|
|
| 625 |
</button>
|
| 626 |
<button
|
| 627 |
onClick={onClose}
|
|
|
|
| 628 |
className="text-muted-foreground hover:text-foreground text-2xl transition-smooth"
|
| 629 |
>
|
| 630 |
×
|
|
@@ -638,10 +649,13 @@ function TaskDetailModal({
|
|
| 638 |
) : (
|
| 639 |
<p className="text-foreground/80 mb-4">No description</p>
|
| 640 |
)}
|
| 641 |
-
<div className="flex gap-2 mt-4">
|
| 642 |
{(['details', 'comments', 'quality'] as const).map(tab => (
|
| 643 |
<button
|
| 644 |
key={tab}
|
|
|
|
|
|
|
|
|
|
| 645 |
onClick={() => setActiveTab(tab)}
|
| 646 |
className={`px-3 py-2 text-sm rounded-md transition-smooth ${
|
| 647 |
activeTab === tab ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:bg-surface-2'
|
|
@@ -653,7 +667,7 @@ function TaskDetailModal({
|
|
| 653 |
</div>
|
| 654 |
|
| 655 |
{activeTab === 'details' && (
|
| 656 |
-
<div className="grid grid-cols-2 gap-4 text-sm mt-4">
|
| 657 |
<div>
|
| 658 |
<span className="text-muted-foreground">Status:</span>
|
| 659 |
<span className="text-foreground ml-2">{task.status}</span>
|
|
@@ -683,7 +697,7 @@ function TaskDetailModal({
|
|
| 683 |
)}
|
| 684 |
|
| 685 |
{activeTab === 'comments' && (
|
| 686 |
-
<div className="mt-6">
|
| 687 |
<div className="flex items-center justify-between mb-3">
|
| 688 |
<h4 className="text-lg font-semibold text-foreground">Comments</h4>
|
| 689 |
<button
|
|
@@ -766,7 +780,7 @@ function TaskDetailModal({
|
|
| 766 |
)}
|
| 767 |
|
| 768 |
{activeTab === 'quality' && (
|
| 769 |
-
<div className="mt-6">
|
| 770 |
<h5 className="text-sm font-medium text-foreground mb-2">Aegis Quality Review</h5>
|
| 771 |
{reviewError && (
|
| 772 |
<div className="text-xs text-red-400 mb-2">{reviewError}</div>
|
|
@@ -873,16 +887,19 @@ function CreateTaskModal({
|
|
| 873 |
}
|
| 874 |
}
|
| 875 |
|
|
|
|
|
|
|
| 876 |
return (
|
| 877 |
-
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
| 878 |
-
<div className="bg-card border border-border rounded-lg max-w-md w-full">
|
| 879 |
<form onSubmit={handleSubmit} className="p-6">
|
| 880 |
-
<h3 className="text-xl font-bold text-foreground mb-4">Create New Task</h3>
|
| 881 |
|
| 882 |
<div className="space-y-4">
|
| 883 |
<div>
|
| 884 |
-
<label className="block text-sm text-muted-foreground mb-1">Title</label>
|
| 885 |
<input
|
|
|
|
| 886 |
type="text"
|
| 887 |
value={formData.title}
|
| 888 |
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
|
@@ -892,8 +909,9 @@ function CreateTaskModal({
|
|
| 892 |
</div>
|
| 893 |
|
| 894 |
<div>
|
| 895 |
-
<label className="block text-sm text-muted-foreground mb-1">Description</label>
|
| 896 |
<textarea
|
|
|
|
| 897 |
value={formData.description}
|
| 898 |
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
| 899 |
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
@@ -903,8 +921,9 @@ function CreateTaskModal({
|
|
| 903 |
|
| 904 |
<div className="grid grid-cols-2 gap-4">
|
| 905 |
<div>
|
| 906 |
-
<label className="block text-sm text-muted-foreground mb-1">Priority</label>
|
| 907 |
<select
|
|
|
|
| 908 |
value={formData.priority}
|
| 909 |
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value as Task['priority'] }))}
|
| 910 |
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
@@ -917,8 +936,9 @@ function CreateTaskModal({
|
|
| 917 |
</div>
|
| 918 |
|
| 919 |
<div>
|
| 920 |
-
<label className="block text-sm text-muted-foreground mb-1">Assign to</label>
|
| 921 |
<select
|
|
|
|
| 922 |
value={formData.assigned_to}
|
| 923 |
onChange={(e) => setFormData(prev => ({ ...prev, assigned_to: e.target.value }))}
|
| 924 |
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
@@ -934,8 +954,9 @@ function CreateTaskModal({
|
|
| 934 |
</div>
|
| 935 |
|
| 936 |
<div>
|
| 937 |
-
<label className="block text-sm text-muted-foreground mb-1">Tags (comma-separated)</label>
|
| 938 |
<input
|
|
|
|
| 939 |
type="text"
|
| 940 |
value={formData.tags}
|
| 941 |
onChange={(e) => setFormData(prev => ({ ...prev, tags: e.target.value }))}
|
|
@@ -1015,16 +1036,19 @@ function EditTaskModal({
|
|
| 1015 |
}
|
| 1016 |
}
|
| 1017 |
|
|
|
|
|
|
|
| 1018 |
return (
|
| 1019 |
-
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
| 1020 |
-
<div className="bg-card border border-border rounded-lg max-w-md w-full">
|
| 1021 |
<form onSubmit={handleSubmit} className="p-6">
|
| 1022 |
-
<h3 className="text-xl font-bold text-foreground mb-4">Edit Task</h3>
|
| 1023 |
|
| 1024 |
<div className="space-y-4">
|
| 1025 |
<div>
|
| 1026 |
-
<label className="block text-sm text-muted-foreground mb-1">Title</label>
|
| 1027 |
<input
|
|
|
|
| 1028 |
type="text"
|
| 1029 |
value={formData.title}
|
| 1030 |
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
|
@@ -1034,8 +1058,9 @@ function EditTaskModal({
|
|
| 1034 |
</div>
|
| 1035 |
|
| 1036 |
<div>
|
| 1037 |
-
<label className="block text-sm text-muted-foreground mb-1">Description</label>
|
| 1038 |
<textarea
|
|
|
|
| 1039 |
value={formData.description}
|
| 1040 |
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
| 1041 |
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
@@ -1045,8 +1070,9 @@ function EditTaskModal({
|
|
| 1045 |
|
| 1046 |
<div className="grid grid-cols-2 gap-4">
|
| 1047 |
<div>
|
| 1048 |
-
<label className="block text-sm text-muted-foreground mb-1">Status</label>
|
| 1049 |
<select
|
|
|
|
| 1050 |
value={formData.status}
|
| 1051 |
onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value as Task['status'] }))}
|
| 1052 |
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
@@ -1061,8 +1087,9 @@ function EditTaskModal({
|
|
| 1061 |
</div>
|
| 1062 |
|
| 1063 |
<div>
|
| 1064 |
-
<label className="block text-sm text-muted-foreground mb-1">Priority</label>
|
| 1065 |
<select
|
|
|
|
| 1066 |
value={formData.priority}
|
| 1067 |
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value as Task['priority'] }))}
|
| 1068 |
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
@@ -1076,8 +1103,9 @@ function EditTaskModal({
|
|
| 1076 |
</div>
|
| 1077 |
|
| 1078 |
<div>
|
| 1079 |
-
<label className="block text-sm text-muted-foreground mb-1">Assign to</label>
|
| 1080 |
<select
|
|
|
|
| 1081 |
value={formData.assigned_to}
|
| 1082 |
onChange={(e) => setFormData(prev => ({ ...prev, assigned_to: e.target.value }))}
|
| 1083 |
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
@@ -1092,8 +1120,9 @@ function EditTaskModal({
|
|
| 1092 |
</div>
|
| 1093 |
|
| 1094 |
<div>
|
| 1095 |
-
<label className="block text-sm text-muted-foreground mb-1">Tags (comma-separated)</label>
|
| 1096 |
<input
|
|
|
|
| 1097 |
type="text"
|
| 1098 |
value={formData.tags}
|
| 1099 |
onChange={(e) => setFormData(prev => ({ ...prev, tags: e.target.value }))}
|
|
|
|
| 3 |
import { useState, useEffect, useCallback, useRef } from 'react'
|
| 4 |
import { useMissionControl } from '@/store'
|
| 5 |
import { useSmartPoll } from '@/lib/use-smart-poll'
|
| 6 |
+
import { useFocusTrap } from '@/lib/use-focus-trap'
|
| 7 |
import { AgentAvatar } from '@/components/ui/agent-avatar'
|
| 8 |
import { MarkdownRenderer } from '@/components/markdown-renderer'
|
| 9 |
|
|
|
|
| 269 |
|
| 270 |
if (loading) {
|
| 271 |
return (
|
| 272 |
+
<div className="flex items-center justify-center h-64" role="status" aria-live="polite">
|
| 273 |
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" aria-hidden="true"></div>
|
| 274 |
<span className="ml-2 text-muted-foreground">Loading tasks...</span>
|
| 275 |
</div>
|
| 276 |
)
|
|
|
|
| 299 |
|
| 300 |
{/* Error Display */}
|
| 301 |
{error && (
|
| 302 |
+
<div role="alert" className="bg-red-500/10 border border-red-500/20 text-red-400 p-3 m-4 rounded-lg text-sm flex items-center justify-between">
|
| 303 |
<span>{error}</span>
|
| 304 |
<button
|
| 305 |
onClick={() => setError(null)}
|
| 306 |
className="text-red-400/60 hover:text-red-400 ml-2"
|
| 307 |
+
aria-label="Dismiss error"
|
| 308 |
>
|
| 309 |
×
|
| 310 |
</button>
|
|
|
|
| 312 |
)}
|
| 313 |
|
| 314 |
{/* Kanban Board */}
|
| 315 |
+
<div className="flex-1 flex gap-4 p-4 overflow-x-auto" role="region" aria-label="Task board">
|
| 316 |
{statusColumns.map(column => (
|
| 317 |
<div
|
| 318 |
key={column.key}
|
| 319 |
+
role="region"
|
| 320 |
+
aria-label={`${column.title} column, ${tasksByStatus[column.key]?.length || 0} tasks`}
|
| 321 |
className="flex-1 min-w-80 bg-card border border-border rounded-lg flex flex-col"
|
| 322 |
onDragEnter={(e) => handleDragEnter(e, column.key)}
|
| 323 |
onDragLeave={handleDragLeave}
|
|
|
|
| 338 |
<div
|
| 339 |
key={task.id}
|
| 340 |
draggable
|
| 341 |
+
role="button"
|
| 342 |
+
tabIndex={0}
|
| 343 |
+
aria-label={`${task.title}, ${task.priority} priority, ${task.status}`}
|
| 344 |
onDragStart={(e) => handleDragStart(e, task)}
|
| 345 |
onClick={() => setSelectedTask(task)}
|
| 346 |
+
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedTask(task) } }}
|
| 347 |
className={`bg-surface-1 rounded-lg p-3 cursor-pointer hover:bg-surface-2 transition-smooth border-l-4 ${priorityColors[task.priority]} ${
|
| 348 |
draggedTask?.id === task.id ? 'opacity-50' : ''
|
| 349 |
}`}
|
|
|
|
| 618 |
</div>
|
| 619 |
)
|
| 620 |
|
| 621 |
+
const dialogRef = useFocusTrap(onClose)
|
| 622 |
+
|
| 623 |
return (
|
| 624 |
+
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
|
| 625 |
+
<div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="task-detail-title" className="bg-card border border-border rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
| 626 |
<div className="p-6">
|
| 627 |
<div className="flex justify-between items-start mb-4">
|
| 628 |
+
<h3 id="task-detail-title" className="text-xl font-bold text-foreground">{task.title}</h3>
|
| 629 |
<div className="flex gap-2">
|
| 630 |
<button
|
| 631 |
onClick={() => onEdit(task)}
|
|
|
|
| 635 |
</button>
|
| 636 |
<button
|
| 637 |
onClick={onClose}
|
| 638 |
+
aria-label="Close task details"
|
| 639 |
className="text-muted-foreground hover:text-foreground text-2xl transition-smooth"
|
| 640 |
>
|
| 641 |
×
|
|
|
|
| 649 |
) : (
|
| 650 |
<p className="text-foreground/80 mb-4">No description</p>
|
| 651 |
)}
|
| 652 |
+
<div className="flex gap-2 mt-4" role="tablist" aria-label="Task detail tabs">
|
| 653 |
{(['details', 'comments', 'quality'] as const).map(tab => (
|
| 654 |
<button
|
| 655 |
key={tab}
|
| 656 |
+
role="tab"
|
| 657 |
+
aria-selected={activeTab === tab}
|
| 658 |
+
aria-controls={`tabpanel-${tab}`}
|
| 659 |
onClick={() => setActiveTab(tab)}
|
| 660 |
className={`px-3 py-2 text-sm rounded-md transition-smooth ${
|
| 661 |
activeTab === tab ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:bg-surface-2'
|
|
|
|
| 667 |
</div>
|
| 668 |
|
| 669 |
{activeTab === 'details' && (
|
| 670 |
+
<div id="tabpanel-details" role="tabpanel" aria-label="Details" className="grid grid-cols-2 gap-4 text-sm mt-4">
|
| 671 |
<div>
|
| 672 |
<span className="text-muted-foreground">Status:</span>
|
| 673 |
<span className="text-foreground ml-2">{task.status}</span>
|
|
|
|
| 697 |
)}
|
| 698 |
|
| 699 |
{activeTab === 'comments' && (
|
| 700 |
+
<div id="tabpanel-comments" role="tabpanel" aria-label="Comments" className="mt-6">
|
| 701 |
<div className="flex items-center justify-between mb-3">
|
| 702 |
<h4 className="text-lg font-semibold text-foreground">Comments</h4>
|
| 703 |
<button
|
|
|
|
| 780 |
)}
|
| 781 |
|
| 782 |
{activeTab === 'quality' && (
|
| 783 |
+
<div id="tabpanel-quality" role="tabpanel" aria-label="Quality Review" className="mt-6">
|
| 784 |
<h5 className="text-sm font-medium text-foreground mb-2">Aegis Quality Review</h5>
|
| 785 |
{reviewError && (
|
| 786 |
<div className="text-xs text-red-400 mb-2">{reviewError}</div>
|
|
|
|
| 887 |
}
|
| 888 |
}
|
| 889 |
|
| 890 |
+
const dialogRef = useFocusTrap(onClose)
|
| 891 |
+
|
| 892 |
return (
|
| 893 |
+
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
|
| 894 |
+
<div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="create-task-title" className="bg-card border border-border rounded-lg max-w-md w-full">
|
| 895 |
<form onSubmit={handleSubmit} className="p-6">
|
| 896 |
+
<h3 id="create-task-title" className="text-xl font-bold text-foreground mb-4">Create New Task</h3>
|
| 897 |
|
| 898 |
<div className="space-y-4">
|
| 899 |
<div>
|
| 900 |
+
<label htmlFor="create-title" className="block text-sm text-muted-foreground mb-1">Title</label>
|
| 901 |
<input
|
| 902 |
+
id="create-title"
|
| 903 |
type="text"
|
| 904 |
value={formData.title}
|
| 905 |
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
|
|
|
| 909 |
</div>
|
| 910 |
|
| 911 |
<div>
|
| 912 |
+
<label htmlFor="create-description" className="block text-sm text-muted-foreground mb-1">Description</label>
|
| 913 |
<textarea
|
| 914 |
+
id="create-description"
|
| 915 |
value={formData.description}
|
| 916 |
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
| 917 |
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
|
|
| 921 |
|
| 922 |
<div className="grid grid-cols-2 gap-4">
|
| 923 |
<div>
|
| 924 |
+
<label htmlFor="create-priority" className="block text-sm text-muted-foreground mb-1">Priority</label>
|
| 925 |
<select
|
| 926 |
+
id="create-priority"
|
| 927 |
value={formData.priority}
|
| 928 |
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value as Task['priority'] }))}
|
| 929 |
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
|
|
| 936 |
</div>
|
| 937 |
|
| 938 |
<div>
|
| 939 |
+
<label htmlFor="create-assignee" className="block text-sm text-muted-foreground mb-1">Assign to</label>
|
| 940 |
<select
|
| 941 |
+
id="create-assignee"
|
| 942 |
value={formData.assigned_to}
|
| 943 |
onChange={(e) => setFormData(prev => ({ ...prev, assigned_to: e.target.value }))}
|
| 944 |
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
|
|
| 954 |
</div>
|
| 955 |
|
| 956 |
<div>
|
| 957 |
+
<label htmlFor="create-tags" className="block text-sm text-muted-foreground mb-1">Tags (comma-separated)</label>
|
| 958 |
<input
|
| 959 |
+
id="create-tags"
|
| 960 |
type="text"
|
| 961 |
value={formData.tags}
|
| 962 |
onChange={(e) => setFormData(prev => ({ ...prev, tags: e.target.value }))}
|
|
|
|
| 1036 |
}
|
| 1037 |
}
|
| 1038 |
|
| 1039 |
+
const dialogRef = useFocusTrap(onClose)
|
| 1040 |
+
|
| 1041 |
return (
|
| 1042 |
+
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
|
| 1043 |
+
<div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="edit-task-title" className="bg-card border border-border rounded-lg max-w-md w-full">
|
| 1044 |
<form onSubmit={handleSubmit} className="p-6">
|
| 1045 |
+
<h3 id="edit-task-title" className="text-xl font-bold text-foreground mb-4">Edit Task</h3>
|
| 1046 |
|
| 1047 |
<div className="space-y-4">
|
| 1048 |
<div>
|
| 1049 |
+
<label htmlFor="edit-title" className="block text-sm text-muted-foreground mb-1">Title</label>
|
| 1050 |
<input
|
| 1051 |
+
id="edit-title"
|
| 1052 |
type="text"
|
| 1053 |
value={formData.title}
|
| 1054 |
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
|
|
|
| 1058 |
</div>
|
| 1059 |
|
| 1060 |
<div>
|
| 1061 |
+
<label htmlFor="edit-description" className="block text-sm text-muted-foreground mb-1">Description</label>
|
| 1062 |
<textarea
|
| 1063 |
+
id="edit-description"
|
| 1064 |
value={formData.description}
|
| 1065 |
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
| 1066 |
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
|
|
| 1070 |
|
| 1071 |
<div className="grid grid-cols-2 gap-4">
|
| 1072 |
<div>
|
| 1073 |
+
<label htmlFor="edit-status" className="block text-sm text-muted-foreground mb-1">Status</label>
|
| 1074 |
<select
|
| 1075 |
+
id="edit-status"
|
| 1076 |
value={formData.status}
|
| 1077 |
onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value as Task['status'] }))}
|
| 1078 |
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
|
|
| 1087 |
</div>
|
| 1088 |
|
| 1089 |
<div>
|
| 1090 |
+
<label htmlFor="edit-priority" className="block text-sm text-muted-foreground mb-1">Priority</label>
|
| 1091 |
<select
|
| 1092 |
+
id="edit-priority"
|
| 1093 |
value={formData.priority}
|
| 1094 |
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value as Task['priority'] }))}
|
| 1095 |
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
|
|
| 1103 |
</div>
|
| 1104 |
|
| 1105 |
<div>
|
| 1106 |
+
<label htmlFor="edit-assignee" className="block text-sm text-muted-foreground mb-1">Assign to</label>
|
| 1107 |
<select
|
| 1108 |
+
id="edit-assignee"
|
| 1109 |
value={formData.assigned_to}
|
| 1110 |
onChange={(e) => setFormData(prev => ({ ...prev, assigned_to: e.target.value }))}
|
| 1111 |
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
|
|
| 1120 |
</div>
|
| 1121 |
|
| 1122 |
<div>
|
| 1123 |
+
<label htmlFor="edit-tags" className="block text-sm text-muted-foreground mb-1">Tags (comma-separated)</label>
|
| 1124 |
<input
|
| 1125 |
+
id="edit-tags"
|
| 1126 |
type="text"
|
| 1127 |
value={formData.tags}
|
| 1128 |
onChange={(e) => setFormData(prev => ({ ...prev, tags: e.target.value }))}
|
src/lib/use-focus-trap.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef, useCallback } from 'react'
|
| 2 |
+
|
| 3 |
+
const FOCUSABLE_SELECTOR = [
|
| 4 |
+
'a[href]',
|
| 5 |
+
'button:not([disabled])',
|
| 6 |
+
'input:not([disabled])',
|
| 7 |
+
'select:not([disabled])',
|
| 8 |
+
'textarea:not([disabled])',
|
| 9 |
+
'[tabindex]:not([tabindex="-1"])',
|
| 10 |
+
].join(', ')
|
| 11 |
+
|
| 12 |
+
/**
|
| 13 |
+
* Traps keyboard focus within a container element.
|
| 14 |
+
* Handles Tab/Shift+Tab cycling and Escape to close.
|
| 15 |
+
*/
|
| 16 |
+
export function useFocusTrap(onClose?: () => void) {
|
| 17 |
+
const containerRef = useRef<HTMLDivElement>(null)
|
| 18 |
+
const previousFocusRef = useRef<HTMLElement | null>(null)
|
| 19 |
+
|
| 20 |
+
const handleKeyDown = useCallback(
|
| 21 |
+
(e: KeyboardEvent) => {
|
| 22 |
+
if (e.key === 'Escape' && onClose) {
|
| 23 |
+
e.stopPropagation()
|
| 24 |
+
onClose()
|
| 25 |
+
return
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
if (e.key !== 'Tab') return
|
| 29 |
+
|
| 30 |
+
const container = containerRef.current
|
| 31 |
+
if (!container) return
|
| 32 |
+
|
| 33 |
+
const focusable = Array.from(
|
| 34 |
+
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
if (focusable.length === 0) return
|
| 38 |
+
|
| 39 |
+
const first = focusable[0]
|
| 40 |
+
const last = focusable[focusable.length - 1]
|
| 41 |
+
|
| 42 |
+
if (e.shiftKey) {
|
| 43 |
+
if (document.activeElement === first) {
|
| 44 |
+
e.preventDefault()
|
| 45 |
+
last.focus()
|
| 46 |
+
}
|
| 47 |
+
} else {
|
| 48 |
+
if (document.activeElement === last) {
|
| 49 |
+
e.preventDefault()
|
| 50 |
+
first.focus()
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
},
|
| 54 |
+
[onClose]
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
useEffect(() => {
|
| 58 |
+
previousFocusRef.current = document.activeElement as HTMLElement
|
| 59 |
+
|
| 60 |
+
const container = containerRef.current
|
| 61 |
+
if (container) {
|
| 62 |
+
const focusable = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
|
| 63 |
+
if (focusable.length > 0) {
|
| 64 |
+
focusable[0].focus()
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
document.addEventListener('keydown', handleKeyDown)
|
| 69 |
+
|
| 70 |
+
return () => {
|
| 71 |
+
document.removeEventListener('keydown', handleKeyDown)
|
| 72 |
+
previousFocusRef.current?.focus()
|
| 73 |
+
}
|
| 74 |
+
}, [handleKeyDown])
|
| 75 |
+
|
| 76 |
+
return containerRef
|
| 77 |
+
}
|