fix(web): complete plugin tab accessibility

This commit is contained in:
ZiyaZhang
2026-08-12 02:41:29 -07:00
parent 14e70b1bf4
commit de0d16d3d1
6 changed files with 59 additions and 7 deletions

View File

@@ -58,7 +58,9 @@
}
.tab:focus-visible {
outline: none;
outline: 2px solid var(--dsw-alias-state-business-primary);
outline-offset: 2px;
border-radius: 2px;
color: var(--dsw-alias-label-primary);
}

View File

@@ -1,6 +1,6 @@
/** Plugins settings section: localized tabs around feature-owned pages. */
import { useEffect, useId, useState } from 'react'
import { useEffect, useId, useRef, useState } from 'react'
import type {
HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime,
} from '@deepseek-ai/dsh-client-ui-slots'
@@ -32,6 +32,7 @@ export type PluginConfigSectionProps =
/** Render one Plugins page whose contents arrive from feature-owned tabs. */
export function PluginConfigSection({ t, renderSlot, useTabs }: PluginConfigSectionProps) {
const tabsId = useId()
const tabRefs = useRef<Array<HTMLButtonElement | null>>([])
const rows = useTabs(value => value)
const [activeId, setActiveId] = useState<string>()
const [visitedIds, setVisitedIds] = useState<ReadonlySet<string>>(() => new Set())
@@ -55,11 +56,12 @@ export function PluginConfigSection({ t, renderSlot, useTabs }: PluginConfigSect
{rows.length === 0 ? <p className={css.empty}>{t('empty')}</p> : (
<>
<div className={css.tabs} role="tablist" aria-label={t('tabs')}>
{rows.map((row) => {
{rows.map((row, index) => {
const selected = row.id === active
return (
<button
key={row.id}
ref={(element) => { tabRefs.current[index] = element }}
id={`${tabsId}-tab-${row.id}`}
type="button"
role="tab"
@@ -67,7 +69,23 @@ export function PluginConfigSection({ t, renderSlot, useTabs }: PluginConfigSect
aria-selected={selected}
aria-controls={`${tabsId}-panel-${row.id}`}
data-active={selected ? 'true' : undefined}
tabIndex={selected ? 0 : -1}
onClick={() => { setActiveId(row.id) }}
onKeyDown={(event) => {
let nextIndex: number
switch (event.key) {
case 'ArrowRight': nextIndex = (index + 1) % rows.length; break
case 'ArrowLeft': nextIndex = (index - 1 + rows.length) % rows.length; break
case 'Home': nextIndex = 0; break
case 'End': nextIndex = rows.length - 1; break
default: return
}
event.preventDefault()
const nextRow = rows[nextIndex] as PluginSettingsTabRow
const nextTab = tabRefs.current[nextIndex] as HTMLButtonElement
setActiveId(nextRow.id)
nextTab.focus()
}}
>
{row.label}
</button>

View File

@@ -117,6 +117,38 @@ describe('PluginConfigSection', () => {
expect(screen.getByRole('heading', { name: en.title })).toBeTruthy()
expect(screen.getByText(en.intro)).toBeTruthy()
})
it('moves focus and selection with standard horizontal tab keys', () => {
renderSection([
{ id: 'configurable', order: 0, label: en.configurableTab },
{ id: 'all', order: 10, label: 'Plugin list' },
{ id: 'diagnostics', order: 20, label: 'Diagnostics' },
])
const configurable = screen.getByRole('tab', { name: en.configurableTab })
const all = screen.getByRole('tab', { name: 'Plugin list' })
const diagnostics = screen.getByRole('tab', { name: 'Diagnostics' })
expect(configurable.getAttribute('tabindex')).toBe('0')
expect(all.getAttribute('tabindex')).toBe('-1')
configurable.focus()
fireEvent.keyDown(configurable, { key: 'ArrowRight' })
expect(document.activeElement).toBe(all)
expect(all.getAttribute('aria-selected')).toBe('true')
fireEvent.keyDown(all, { key: 'End' })
expect(document.activeElement).toBe(diagnostics)
fireEvent.keyDown(diagnostics, { key: 'ArrowRight' })
expect(document.activeElement).toBe(configurable)
fireEvent.keyDown(configurable, { key: 'ArrowLeft' })
expect(document.activeElement).toBe(diagnostics)
fireEvent.keyDown(diagnostics, { key: 'Home' })
expect(document.activeElement).toBe(configurable)
fireEvent.keyDown(configurable, { key: 'Escape' })
expect(document.activeElement).toBe(configurable)
expect(configurable.getAttribute('aria-selected')).toBe('true')
})
})
describe('ConfigurablePluginsTab', () => {