Skip to content

Commit 9bdabbe

Browse files
✨ Add keyboard navigation to dropdowns and tab components (#15823)
* ✨ Add keyboard navigation to dropdown menus for a11y compliance - Apply useDropdownKeyNav hook to Weather card city search dropdown - Apply useDropdownKeyNav hook to HardwareHealthCardHeader snooze menu - Add proper ARIA roles (listbox, option, menu, menuitem) for screen readers - Enable ArrowUp/ArrowDown navigation through dropdown items - Enable Escape key to close dropdowns These changes improve keyboard accessibility for users who cannot use a mouse, addressing Auto-QA findings from issue #15822. Fixes #15822 Signed-off-by: Copilot <223556219+Copilot@users.noreply.github.com> * ✨ Add keyboard navigation to dropdowns and tab components Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Copilot <223556219+Copilot@users.noreply.github.com> * 🐛 Fix tabListProps ref type mismatch causing TS2322 Remove ref from tabListProps return to avoid RefObject<HTMLElement> not being assignable to Ref<HTMLDivElement>. The tab keyboard handler uses event.currentTarget via moveFocusByKey instead of a stored ref. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Signed-off-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f3da21f commit 9bdabbe

12 files changed

Lines changed: 460 additions & 69 deletions

web/src/components/cards/HardwareHealthCardHeader.tsx

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { memo, type RefObject } from 'react'
1+
import { memo, useEffect, type RefObject } from 'react'
22

33
// Split helper component; parent card owns useCardLoadingState.
44
import { AlertCircle, AlertTriangle, BellOff, Clock, List, MoreVertical, RefreshCw } from 'lucide-react'
55
import { useTranslation } from 'react-i18next'
66
import { CardControlsRow, CardSearchInput } from '../../lib/cards/CardComponents'
77
import { SNOOZE_DURATIONS, type SnoozeDuration } from '../../hooks/useSnoozedAlerts'
8+
import { useKeyboardNav, useTabKeyboardNav } from '../../hooks/useKeyboardNav'
89
import { cn } from '../../lib/cn'
910
import { StatusBadge } from '../ui/StatusBadge'
1011
import { CARD_UI_STRINGS } from './strings'
@@ -92,6 +93,13 @@ export const HardwareHealthCardHeader = memo(function HardwareHealthCardHeader({
9293
isDemoData,
9394
}: HardwareHealthCardHeaderProps) {
9495
const { t } = useTranslation(['cards', 'common'])
96+
const snoozeMenuNav = useKeyboardNav({ selector: '[role="menuitem"]:not([disabled])', orientation: 'vertical', onEscape: onToggleSnoozeAllMenu })
97+
const { tabListProps, getTabProps } = useTabKeyboardNav<ViewMode>({ tabs: ['inventory', 'alerts'], activeTab: viewMode, onChange: onViewModeChange })
98+
99+
useEffect(() => {
100+
if (!snoozeAllMenuOpen) return
101+
snoozeMenuNav.focusMatchingItem({ fallbackSelector: '[role="menuitem"]:not([disabled])' })
102+
}, [snoozeAllMenuOpen, snoozeMenuNav])
95103

96104
return (
97105
<>
@@ -139,12 +147,11 @@ export const HardwareHealthCardHeader = memo(function HardwareHealthCardHeader({
139147
</div>
140148

141149
<div className="flex flex-wrap gap-2 mb-3">
142-
<div className="flex flex-1 min-w-0 bg-muted/30 rounded-lg p-0.5">
150+
<div {...tabListProps} className="flex flex-1 min-w-0 bg-muted/30 rounded-lg p-0.5">
143151
<button
144-
onClick={() => onViewModeChange('inventory')}
152+
{...getTabProps('inventory')}
145153
className={cn('flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors', viewMode === 'inventory' ? 'bg-background text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground')}
146154
aria-label={t('cards:hardwareHealth.switchToInventoryAria')}
147-
aria-pressed={viewMode === 'inventory'}
148155
>
149156
<List className="w-3.5 h-3.5" />
150157
{t('cards:hardwareHealth.inventory', 'Inventory')}
@@ -153,10 +160,9 @@ export const HardwareHealthCardHeader = memo(function HardwareHealthCardHeader({
153160
)}
154161
</button>
155162
<button
156-
onClick={() => onViewModeChange('alerts')}
163+
{...getTabProps('alerts')}
157164
className={cn('flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors', viewMode === 'alerts' ? 'bg-background text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground')}
158165
aria-label={t('cards:hardwareHealth.switchToAlertsAria')}
159-
aria-pressed={viewMode === 'alerts'}
160166
>
161167
<AlertCircle className="w-3.5 h-3.5" />
162168
{t('cards:hardwareHealth.alerts', 'Alerts')}
@@ -194,11 +200,19 @@ export const HardwareHealthCardHeader = memo(function HardwareHealthCardHeader({
194200
<MoreVertical className="w-4 h-4" />
195201
</button>
196202
{snoozeAllMenuOpen && (
197-
<div className="absolute right-0 top-full mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg py-1 min-w-[160px]">
203+
<div
204+
ref={(node) => {
205+
snoozeMenuNav.containerRef.current = node
206+
}}
207+
role="menu"
208+
className="absolute right-0 top-full mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg py-1 min-w-[160px]"
209+
onKeyDown={snoozeMenuNav.handleKeyDown}
210+
>
198211
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground border-b border-border mb-1">{t('cards:hardwareHealth.snoozeAllVisibleCount', 'Snooze All ({{count}})', { count: visibleAlertIds.length })}</div>
199212
{(Object.keys(SNOOZE_DURATIONS) as SnoozeDuration[]).map(duration => (
200213
<button
201214
key={duration}
215+
role="menuitem"
202216
onClick={() => onSnoozeAll(duration)}
203217
className="w-full px-3 py-1.5 text-xs text-left hover:bg-muted/50 transition-colors flex items-center gap-2"
204218
aria-label={t('cards:hardwareHealth.snoozeAllForAria', { duration })}
@@ -211,6 +225,7 @@ export const HardwareHealthCardHeader = memo(function HardwareHealthCardHeader({
211225
<>
212226
<div className="border-t border-border my-1" />
213227
<button
228+
role="menuitem"
214229
onClick={onClearAllSnoozed}
215230
className="w-full px-3 py-1.5 text-xs text-left text-yellow-400 hover:bg-muted/50 transition-colors"
216231
aria-label={t('cards:hardwareHealth.clearAllSnoozesAria')}

web/src/components/cards/weather/Weather.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { WeatherAnimation, getWeatherCondition, getConditionColor } from './Weat
99
import { WEATHER_API } from '../../../config/externalApis'
1010
import { useCardLoadingState } from '../CardDataContext'
1111
import { RefreshIndicator } from '../../ui/RefreshIndicator'
12+
import { useKeyboardNav } from '../../../hooks/useKeyboardNav'
1213
import { useCache } from '../../../lib/cache'
1314
import { FETCH_EXTERNAL_TIMEOUT_MS } from '../../../lib/constants'
1415
import { useToast } from '../../ui/Toast'
@@ -105,6 +106,7 @@ export function Weather({ config }: { config?: WeatherConfig }) {
105106
const [isSearching, setIsSearching] = useState(false)
106107
const [showCityDropdown, setShowCityDropdown] = useState(false)
107108
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined)
109+
const cityDropdownNav = useKeyboardNav({ selector: '[role="option"]:not([disabled])', orientation: 'vertical', onEscape: () => setShowCityDropdown(false) })
108110

109111
// security: stored in sessionStorage, not localStorage — location list is
110112
// user-provided and only used client-side; clears on tab close to reduce exposure window
@@ -263,6 +265,11 @@ export function Weather({ config }: { config?: WeatherConfig }) {
263265
}
264266
}, [citySearchInput, searchCities])
265267

268+
useEffect(() => {
269+
if (!showCityDropdown || citySearchResults.length === 0) return
270+
cityDropdownNav.focusMatchingItem({ fallbackSelector: '[role="option"]:not([disabled])' })
271+
}, [cityDropdownNav, citySearchResults.length, showCityDropdown])
272+
266273
// Select city from search results
267274
const selectCity = (city: GeocodingResult) => {
268275
const statePart = city.admin1 || city.country
@@ -361,21 +368,39 @@ export function Weather({ config }: { config?: WeatherConfig }) {
361368
value={citySearchInput}
362369
onChange={(e) => setCitySearchInput(e.target.value)}
363370
onFocus={() => citySearchResults.length > 0 && setShowCityDropdown(true)}
371+
onKeyDown={(event) => {
372+
if (event.key !== 'ArrowDown' || citySearchResults.length === 0) return
373+
event.preventDefault()
374+
setShowCityDropdown(true)
375+
}}
364376
className="w-full pl-10 pr-10 py-2.5 text-sm rounded-lg bg-secondary/50 border border-border/30 text-foreground placeholder:text-muted-foreground"
365377
placeholder="Type city name..."
378+
aria-expanded={showCityDropdown}
379+
aria-controls="weather-city-results"
380+
aria-autocomplete="list"
366381
/>
367382
{isSearching && (
368383
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground animate-spin" />
369384
)}
370385

371386
{/* City Search Dropdown */}
372387
{showCityDropdown && citySearchResults.length > 0 && (
373-
<div className="absolute z-50 w-full mt-1 bg-secondary/95 backdrop-blur-xs border border-border/30 rounded-lg shadow-lg max-h-60 overflow-y-auto">
388+
<div
389+
id="weather-city-results"
390+
ref={(node) => {
391+
cityDropdownNav.containerRef.current = node
392+
}}
393+
className="absolute z-50 w-full mt-1 bg-secondary/95 backdrop-blur-xs border border-border/30 rounded-lg shadow-lg max-h-60 overflow-y-auto"
394+
onKeyDown={cityDropdownNav.handleKeyDown}
395+
role="listbox"
396+
aria-label="City search results"
397+
>
374398
{citySearchResults.map((city) => (
375399
<button
376400
key={city.id}
377401
onClick={() => selectCity(city)}
378402
className="w-full text-left px-3 py-2.5 hover:bg-secondary transition-colors border-b border-border last:border-0"
403+
role="option"
379404
>
380405
<div className="text-sm font-medium">{city.name}</div>
381406
<div className="text-xs text-muted-foreground">

web/src/components/drilldown/views/AlertDrillDown.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
Code
1111
} from 'lucide-react'
1212
import { cn } from '../../../lib/cn'
13+
import { useTabKeyboardNav } from '../../../hooks/useKeyboardNav'
1314
import { UI_FEEDBACK_TIMEOUT_MS } from '../../../lib/constants/network'
1415
import { ConsoleAIIcon } from '../../ui/ConsoleAIIcon'
1516
import {
@@ -83,6 +84,7 @@ export function AlertDrillDown({ data }: Props) {
8384
const [aiAnalysis] = useState<string | null>(null)
8485
const [aiAnalysisLoading] = useState(false)
8586
const [copiedField, setCopiedField] = useState<string | null>(null)
87+
const { tabListProps, getTabProps, getTabPanelProps } = useTabKeyboardNav<TabType>({ tabs: ['overview', 'labels', 'source', 'ai'], activeTab, onChange: setActiveTab })
8688
const mountedRef = useRef(true)
8789
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
8890

@@ -295,13 +297,13 @@ Please:
295297

296298
{/* Tabs */}
297299
<div className="border-b border-border px-6">
298-
<div className="flex gap-1">
300+
<div {...tabListProps} className="flex gap-1">
299301
{TABS.map((tab) => {
300302
const Icon = tab.icon
301303
return (
302304
<button
303305
key={tab.id}
304-
onClick={() => setActiveTab(tab.id)}
306+
{...getTabProps(tab.id)}
305307
className={cn(
306308
'px-4 py-2 text-sm font-medium flex items-center gap-2 border-b-2 transition-colors',
307309
activeTab === tab.id
@@ -320,7 +322,7 @@ Please:
320322
{/* Tab Content */}
321323
<div className="flex-1 overflow-y-auto p-6 space-y-6">
322324
{activeTab === 'overview' && (
323-
<div className="space-y-6">
325+
<div {...getTabPanelProps('overview')} className="space-y-6">
324326
{/* Alert Info Card */}
325327
<div className={cn('p-4 rounded-lg border', severityStyle.bg, severityStyle.border)}>
326328
<div className="flex items-start gap-3">
@@ -429,7 +431,7 @@ Please:
429431
)}
430432

431433
{activeTab === 'labels' && (
432-
<div className="space-y-4">
434+
<div {...getTabPanelProps('labels')} className="space-y-4">
433435
<div className="flex items-center justify-between">
434436
<h4 className="text-sm font-medium text-foreground">{t('drilldown.alertDetail.allLabels', { count: labelEntries.length })}</h4>
435437
</div>
@@ -466,7 +468,7 @@ Please:
466468
)}
467469

468470
{activeTab === 'source' && (
469-
<div className="space-y-4">
471+
<div {...getTabPanelProps('source')} className="space-y-4">
470472
<h4 className="text-sm font-medium text-foreground">{t('drilldown.alertDetail.alertRuleSource')}</h4>
471473
{sourceLoading ? (
472474
<div className="flex items-center justify-center py-12">
@@ -487,7 +489,7 @@ Please:
487489
)}
488490

489491
{activeTab === 'ai' && (
490-
<div className="space-y-4">
492+
<div {...getTabPanelProps('ai')} className="space-y-4">
491493
<div className="flex items-center justify-between">
492494
<h4 className="text-sm font-medium text-foreground flex items-center gap-2">
493495
<ConsoleAIIcon className="w-5 h-5" />

web/src/components/drilldown/views/ConfigMapDrillDown.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useDrillDownActions } from '../../../hooks/useDrillDown'
55
import { ClusterBadge } from '../../ui/ClusterBadge'
66
import { FileText, Code, Info, Tag, ChevronDown, ChevronUp, Loader2, Copy, Check, Layers, Server, Database, Eye, EyeOff, Lock } from 'lucide-react'
77
import { cn } from '../../../lib/cn'
8+
import { useTabKeyboardNav } from '../../../hooks/useKeyboardNav'
89
import { UI_FEEDBACK_TIMEOUT_MS } from '../../../lib/constants/network'
910
import { useTranslation } from 'react-i18next'
1011
import { copyToClipboard } from '../../../lib/clipboard'
@@ -48,6 +49,7 @@ export function ConfigMapDrillDown({ data }: Props) {
4849
// alongside the per-key buttons for the common case.
4950
const [revealedKeys, setRevealedKeys] = useState<Set<string>>(new Set())
5051
const [revealAll, setRevealAll] = useState(false)
52+
const { tabListProps, getTabProps, getTabPanelProps } = useTabKeyboardNav<TabType>({ tabs: ['overview', 'data', 'describe', 'yaml'], activeTab, onChange: setActiveTab })
5153

5254
const toggleReveal = (key: string) => {
5355
setRevealedKeys(prev => {
@@ -190,13 +192,13 @@ export function ConfigMapDrillDown({ data }: Props) {
190192

191193
{/* Tabs */}
192194
<div className="border-b border-border px-6">
193-
<div className="flex gap-1">
195+
<div {...tabListProps} className="flex gap-1">
194196
{TABS.map((tab) => {
195197
const Icon = tab.icon
196198
return (
197199
<button
198200
key={tab.id}
199-
onClick={() => setActiveTab(tab.id)}
201+
{...getTabProps(tab.id)}
200202
className={cn(
201203
'px-4 py-2 text-sm font-medium flex items-center gap-2 border-b-2 transition-colors',
202204
activeTab === tab.id
@@ -215,7 +217,7 @@ export function ConfigMapDrillDown({ data }: Props) {
215217
{/* Tab Content */}
216218
<div className="flex-1 overflow-y-auto p-6 space-y-6">
217219
{activeTab === 'overview' && (
218-
<div className="space-y-6">
220+
<div {...getTabPanelProps('overview')} className="space-y-6">
219221
{!hasRequiredContext && (
220222
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20 text-sm text-yellow-400">
221223
{t('drilldown.configmap.missingContext', 'Unable to load this ConfigMap because the selected resource is missing required details.')}
@@ -276,7 +278,7 @@ export function ConfigMapDrillDown({ data }: Props) {
276278
)}
277279

278280
{activeTab === 'data' && (
279-
<div className="space-y-4">
281+
<div {...getTabPanelProps('data')} className="space-y-4">
280282
{/* #6211: master reveal toggle for ConfigMaps. ConfigMaps often
281283
hold non-sensitive config so a single "Reveal all" is more
282284
ergonomic than the per-key dance, but we keep the per-key
@@ -354,7 +356,7 @@ export function ConfigMapDrillDown({ data }: Props) {
354356
)}
355357

356358
{activeTab === 'describe' && (
357-
<div>
359+
<div {...getTabPanelProps('describe')}>
358360
{describeLoading ? (
359361
<div className="flex items-center justify-center py-12">
360362
<Loader2 className="w-6 h-6 animate-spin text-primary" />
@@ -381,7 +383,7 @@ export function ConfigMapDrillDown({ data }: Props) {
381383
)}
382384

383385
{activeTab === 'yaml' && (
384-
<div className="space-y-3">
386+
<div {...getTabPanelProps('yaml')} className="space-y-3">
385387
{/* #6211: same masking model the Data tab uses, applied to the
386388
YAML view so that tab isn't a bypass for the per-key reveal.
387389
ConfigMap YAML uses the same `data:` block shape as Secret

web/src/components/drilldown/views/NamespaceDrillDown.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ClusterBadge } from '../../ui/ClusterBadge'
77
import { StatusIndicator } from '../../charts/StatusIndicator'
88
import { StatusBadge } from '../../ui/StatusBadge'
99
import { useTranslation } from 'react-i18next'
10+
import { useTabKeyboardNav } from '../../../hooks/useKeyboardNav'
1011
import { cn } from '../../../lib/cn'
1112

1213
type TabType = 'issues' | 'events' | 'resources'
@@ -25,6 +26,7 @@ export function NamespaceDrillDown({ data }: Props) {
2526
const [activeTab, setActiveTab] = useState<TabType>('issues')
2627
const [resourceFilter, setResourceFilter] = useState<ResourceFilter>('all')
2728
const [resourceSearch, setResourceSearch] = useState('')
29+
const { tabListProps, getTabProps, getTabPanelProps } = useTabKeyboardNav<TabType>({ tabs: ['issues', 'events', 'resources'], activeTab, onChange: setActiveTab })
2830

2931
const { issues: allPodIssues } = usePodIssues(cluster)
3032
const { issues: allDeploymentIssues } = useDeploymentIssues()
@@ -122,11 +124,11 @@ export function NamespaceDrillDown({ data }: Props) {
122124

123125
{/* Tabs */}
124126
<div className="border-b border-border">
125-
<div className="flex gap-0">
127+
<div {...tabListProps} className="flex gap-0">
126128
{tabs.map(tab => (
127129
<button
128130
key={tab.id}
129-
onClick={() => setActiveTab(tab.id)}
131+
{...getTabProps(tab.id)}
130132
className={cn(
131133
'px-4 py-2 text-sm font-medium flex items-center gap-2 border-b-2 transition-colors',
132134
activeTab === tab.id
@@ -152,7 +154,7 @@ export function NamespaceDrillDown({ data }: Props) {
152154

153155
{/* Tab Content */}
154156
{activeTab === 'issues' && (
155-
<div className="space-y-6">
157+
<div {...getTabPanelProps('issues')} className="space-y-6">
156158
{/* Deployment Issues */}
157159
{deploymentIssues.length > 0 && (
158160
<div>
@@ -240,7 +242,7 @@ export function NamespaceDrillDown({ data }: Props) {
240242
)}
241243

242244
{activeTab === 'events' && (
243-
<div className="space-y-4">
245+
<div {...getTabPanelProps('events')} className="space-y-4">
244246
{/* Quick action to view full events drilldown */}
245247
<div className="flex justify-end">
246248
<button
@@ -280,7 +282,7 @@ export function NamespaceDrillDown({ data }: Props) {
280282
)}
281283

282284
{activeTab === 'resources' && (
283-
<div className="space-y-4">
285+
<div {...getTabPanelProps('resources')} className="space-y-4">
284286
{/* Search and Filter Controls */}
285287
<div className="flex flex-col md:flex-row gap-3">
286288
<div className="relative flex-1">

0 commit comments

Comments
 (0)