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.
107 lines
4.6 KiB
JavaScript
107 lines
4.6 KiB
JavaScript
import { describe, it, expect } from 'vitest'
|
|
import { mount } from '@vue/test-utils'
|
|
import KanbanBoard from '../src/components/KanbanBoard.vue'
|
|
|
|
function makeItem(overrides = {}) {
|
|
return {
|
|
id: 1,
|
|
modality: 'bh_email',
|
|
sender_id: 'jane@example.com',
|
|
raw_content: 'I have some books to donate.',
|
|
type: null,
|
|
stage: 'new',
|
|
direction: 'inbound',
|
|
follow_up_date: '',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
describe('KanbanBoard', () => {
|
|
it('buckets items into the correct board and shows counts on tabs', () => {
|
|
const items = [
|
|
makeItem({ id: 1, type: 'donation', direction: 'inbound', stage: 'new' }),
|
|
makeItem({ id: 2, type: 'other', direction: 'inbound', stage: 'new' }),
|
|
makeItem({ id: 3, type: null, direction: 'outbound', stage: 'new' }),
|
|
makeItem({ id: 4, type: null, direction: 'inbound', stage: 'new' }),
|
|
]
|
|
const wrapper = mount(KanbanBoard, { props: { items } })
|
|
const tabs = wrapper.findAll('[role="tab"]').map((t) => t.text())
|
|
expect(tabs).toEqual([
|
|
'Donations (1)', 'Other (1)', 'Outbound Leads (1)', 'Unsorted (1)',
|
|
])
|
|
})
|
|
|
|
it('defaults to the Donations board and its first stage', () => {
|
|
const wrapper = mount(KanbanBoard, { props: { items: [] } })
|
|
const activeTab = wrapper.find('[role="tab"][aria-selected="true"]')
|
|
expect(activeTab.text()).toContain('Donations')
|
|
})
|
|
|
|
it('shows humanized stage labels, not raw stage strings, on column headers', () => {
|
|
const items = [makeItem({ id: 1, type: 'donation', stage: 'pickup_scheduled' })]
|
|
const wrapper = mount(KanbanBoard, { props: { items } })
|
|
expect(wrapper.text()).toContain('Pickup Scheduled')
|
|
expect(wrapper.text()).not.toContain('pickup_scheduled')
|
|
})
|
|
|
|
it('switching board tabs resets the active stage to that board\'s first stage', async () => {
|
|
const wrapper = mount(KanbanBoard, { props: { items: [] } })
|
|
const outboundTab = wrapper.findAll('[role="tab"]').find((t) => t.text().includes('Outbound Leads'))
|
|
await outboundTab.trigger('click')
|
|
const breadcrumb = wrapper.find('.mobile-breadcrumb')
|
|
expect(breadcrumb.text()).toContain('New')
|
|
expect(breadcrumb.text()).toContain('Outbound Leads')
|
|
})
|
|
|
|
it('clicking a stage-strip button switches the active stage', async () => {
|
|
const items = [makeItem({ id: 1, type: 'donation', stage: 'replied' })]
|
|
const wrapper = mount(KanbanBoard, { props: { items } })
|
|
const repliedButton = wrapper.findAll('.stage-strip-button').find((b) => b.text().includes('Replied'))
|
|
await repliedButton.trigger('click')
|
|
expect(wrapper.find('.mobile-breadcrumb').text()).toContain('Replied')
|
|
})
|
|
|
|
it('clicking a card emits select-item with the item id', async () => {
|
|
const items = [makeItem({ id: 42, type: 'donation', stage: 'new' })]
|
|
const wrapper = mount(KanbanBoard, { props: { items } })
|
|
await wrapper.find('.card').trigger('click')
|
|
expect(wrapper.emitted('select-item')).toEqual([[42]])
|
|
})
|
|
|
|
it('renders an empty-state message for a column with no items', () => {
|
|
const wrapper = mount(KanbanBoard, { props: { items: [] } })
|
|
expect(wrapper.text()).toContain('Nothing here')
|
|
})
|
|
|
|
it('applies the is-active-mobile class only to the currently active stage column', () => {
|
|
const items = [
|
|
makeItem({ id: 1, type: 'donation', stage: 'new' }),
|
|
makeItem({ id: 2, type: 'donation', stage: 'replied' }),
|
|
]
|
|
const wrapper = mount(KanbanBoard, { props: { items } })
|
|
const columns = wrapper.findAll('.column')
|
|
const activeColumns = columns.filter((c) => c.classes().includes('is-active-mobile'))
|
|
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()
|
|
})
|
|
})
|