fix: add arrow-key roving tabindex nav and aria-pressed to triage views

KanbanBoard's board tabs implemented roving tabindex but never wired up
arrow-key navigation, making inactive tabs keyboard-unreachable. Adds
ArrowLeft/ArrowRight/Home/End handling that moves both selection and
focus between tabs, closing an unmet requirement from the original
design doc. Also adds aria-pressed to App's List/Board toggle buttons
so their active state is exposed to assistive technology, not just
conveyed visually via CSS class.
This commit is contained in:
pyr0ball 2026-07-14 17:51:43 -07:00
parent 18cfd22db5
commit 575fb3aaeb
4 changed files with 60 additions and 2 deletions

View file

@ -67,6 +67,7 @@ onMounted(async () => {
<div class="view-toggle">
<button
type="button"
:aria-pressed="viewMode === 'list'"
:class="{ active: viewMode === 'list' }"
@click="viewMode = 'list'"
>
@ -74,6 +75,7 @@ onMounted(async () => {
</button>
<button
type="button"
:aria-pressed="viewMode === 'board'"
:class="{ active: viewMode === 'board' }"
@click="viewMode = 'board'"
>

View file

@ -1,5 +1,5 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, nextTick } from 'vue'
import ModalityBadge from './ModalityBadge.vue'
import { DONATION_STAGES, OTHER_STAGES, OUTBOUND_STAGES, UNSORTED_STAGES, STAGE_LABELS } from '../stages.js'
@ -50,6 +50,31 @@ function selectBoard(key) {
activeBoardKey.value = key
}
const tabRefs = ref([])
function setTabRef(el, index) {
tabRefs.value[index] = el
}
async function handleTabKeydown(event, index) {
let nextIndex = null
if (event.key === 'ArrowRight') {
nextIndex = (index + 1) % BOARDS.length
} else if (event.key === 'ArrowLeft') {
nextIndex = (index - 1 + BOARDS.length) % BOARDS.length
} else if (event.key === 'Home') {
nextIndex = 0
} else if (event.key === 'End') {
nextIndex = BOARDS.length - 1
} else {
return
}
event.preventDefault()
selectBoard(BOARDS[nextIndex].key)
await nextTick()
tabRefs.value[nextIndex]?.focus()
}
function selectStage(stage) {
activeStage.value = stage
}
@ -63,8 +88,9 @@ function labelFor(stage) {
<div class="kanban-board">
<div class="board-tabs" role="tablist" aria-label="Item category">
<button
v-for="board in BOARDS"
v-for="(board, index) in BOARDS"
:key="board.key"
:ref="el => setTabRef(el, index)"
role="tab"
:id="`board-tab-${board.key}`"
:aria-selected="board.key === activeBoardKey"
@ -74,6 +100,7 @@ function labelFor(stage) {
class="board-tab"
:class="{ active: board.key === activeBoardKey }"
@click="selectBoard(board.key)"
@keydown="handleTabKeydown($event, index)"
>
{{ board.label }} ({{ boardCount(board.key) }})
</button>

View file

@ -42,4 +42,13 @@ describe('App view mode', () => {
await boardButton.trigger('click')
expect(localStorage.getItem('chorus_view_mode')).toBe('board')
})
it('sets aria-pressed correctly on the view toggle buttons', async () => {
const wrapper = mount(App)
await flushPromises()
const listButton = wrapper.findAll('.view-toggle button').find((b) => b.text() === 'List')
const boardButton = wrapper.findAll('.view-toggle button').find((b) => b.text() === 'Board')
expect(listButton.attributes('aria-pressed')).toBe('true')
expect(boardButton.attributes('aria-pressed')).toBe('false')
})
})

View file

@ -84,4 +84,24 @@ describe('KanbanBoard', () => {
expect(activeColumns).toHaveLength(1)
expect(activeColumns[0].text()).toContain('New')
})
it('ArrowRight on a board tab moves selection and focus to the next tab', async () => {
const wrapper = mount(KanbanBoard, { props: { items: [] }, attachTo: document.body })
const tabs = wrapper.findAll('[role="tab"]')
tabs[0].element.focus()
await tabs[0].trigger('keydown', { key: 'ArrowRight' })
const updatedTabs = wrapper.findAll('[role="tab"]')
expect(updatedTabs[1].attributes('aria-selected')).toBe('true')
expect(document.activeElement).toBe(updatedTabs[1].element)
wrapper.unmount()
})
it('ArrowLeft wraps from the first tab to the last tab', async () => {
const wrapper = mount(KanbanBoard, { props: { items: [] }, attachTo: document.body })
const tabs = wrapper.findAll('[role="tab"]')
await tabs[0].trigger('keydown', { key: 'ArrowLeft' })
const updatedTabs = wrapper.findAll('[role="tab"]')
expect(updatedTabs[3].attributes('aria-selected')).toBe('true')
wrapper.unmount()
})
})