nyk commited on
Commit
2aff45a
·
unverified ·
2 Parent(s): b1106ba61f5832

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
+ }