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:
parent
18cfd22db5
commit
575fb3aaeb
4 changed files with 60 additions and 2 deletions
|
|
@ -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'"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue