Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 178 additions & 4 deletions packages/ui/client/components/explorer/Explorer.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
<script setup lang="ts">
import type { RunnerTestFile as File, RunnerTask as Task } from 'vitest'
import type { UITaskTreeNode } from '~/composables/explorer/types'
import { hideAllPoppers } from 'floating-vue'
import { computed, ref } from 'vue'
import { computed, nextTick, ref } from 'vue'

import { RecycleScroller } from 'vue-virtual-scroller'
import { availableProjects, config } from '~/composables/client'
import { availableProjects, client, config } from '~/composables/client'
import { explorerTree } from '~/composables/explorer'
import { useSearch } from '~/composables/explorer/search'
import { ALL_PROJECTS, projectSort } from '~/composables/explorer/state'
import { activeFileId, selectedTest } from '~/composables/params'
Expand Down Expand Up @@ -38,6 +40,13 @@ const slowTime = computed(() => {
const searchBox = ref<HTMLInputElement | undefined>()
const selectProjectRef = ref<HTMLSelectElement | undefined>()
const sortProjectRef = ref<HTMLSelectElement | undefined>()
const scrollerRef = ref<{
el?: HTMLElement
scrollToItem: (index: number, options?: { align?: 'nearest' }) => void
}>()
const focusedTaskId = ref<string>()
const ariaActiveTaskId = ref<string>()
const selectedTaskId = ref<string>()

const {
initialized,
Expand All @@ -62,6 +71,154 @@ const {
disableClearProjectSort,
searchMatcher,
} = useSearch(searchBox, selectProjectRef, sortProjectRef)

const activeTaskId = computed(() => {
if (uiEntries.value.some(item => item.id === focusedTaskId.value)) {
return focusedTaskId.value
}
if (selectedTest.value && uiEntries.value.some(item => item.id === selectedTest.value)) {
return selectedTest.value
}
if (uiEntries.value.some(item => item.id === activeFileId.value)) {
return activeFileId.value
}
return uiEntries.value[0]?.id
})

const treeItemAria = computed(() => {
const siblings = new Map<string, UITaskTreeNode[]>()
for (const item of uiEntries.value) {
const entries = siblings.get(item.parentId) ?? []
entries.push(item)
siblings.set(item.parentId, entries)
}

const metadata = new Map<string, { posinset: number; setsize: number }>()
for (const entries of siblings.values()) {
for (let i = 0; i < entries.length; i++) {
metadata.set(entries[i].id, { posinset: i + 1, setsize: entries.length })
}
}
return metadata
})

function isSelected(taskId: string) {
const selectedId = selectedTaskId.value ?? selectedTest.value ?? activeFileId.value
return selectedId === taskId
}

function getTreeItemElement(taskId: string) {
return document.getElementById(`explorer-item-${taskId}`)
}

async function updateAriaActiveTask(taskId: string, index: number) {
if (!getTreeItemElement(taskId)) {
scrollerRef.value?.scrollToItem(index, { align: 'nearest' })
for (let i = 0; i < 3 && !getTreeItemElement(taskId); i++) {
await nextTick()
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
}
}
if (focusedTaskId.value === taskId && getTreeItemElement(taskId)) {
ariaActiveTaskId.value = taskId
}
}

function selectItem(index: number) {
const item = uiEntries.value[index]
const task = item && client.state.idMap.get(item.id)
if (!item || !task) {
return
}

focusedTaskId.value = item.id
selectedTaskId.value = item.id
onItemClick?.(task)
void updateAriaActiveTask(item.id, index)
}

function onTreeKeydown(event: KeyboardEvent) {
const target = event.target
if (!(target instanceof HTMLElement) || target.getAttribute('role') !== 'tree') {
return
}

const index = uiEntries.value.findIndex(item => item.id === activeTaskId.value)
const item = uiEntries.value[index]
if (!item) {
return
}

let nextIndex: number | undefined
if (event.key === 'ArrowUp') {
nextIndex = Math.max(index - 1, 0)
}
else if (event.key === 'ArrowDown') {
nextIndex = Math.min(index + 1, uiEntries.value.length - 1)
}
else if (event.key === 'Home') {
nextIndex = 0
}
else if (event.key === 'End') {
nextIndex = uiEntries.value.length - 1
}
else if (event.key === 'ArrowRight') {
if (item.expandable && !item.expanded) {
explorerTree.expandNode(item.id)
}
else if (uiEntries.value[index + 1]?.indent > item.indent) {
nextIndex = index + 1
}
}
else if (event.key === 'ArrowLeft') {
if (item.expandable && item.expanded) {
explorerTree.collapseNode(item.id)
}
else {
nextIndex = uiEntries.value.findIndex(entry => entry.id === item.parentId)
}
}
else if (event.key === 'Enter') {
const task = client.state.idMap.get(item.id)
if (task) {
selectedTaskId.value = item.id
onItemClick?.(task)
}
}
else if (event.key !== ' ') {
return
}

event.preventDefault()
if (nextIndex !== undefined && nextIndex >= 0 && nextIndex !== index) {
selectItem(nextIndex)
}
}

function onTreeFocus(event: FocusEvent) {
if (event.target === event.currentTarget && !focusedTaskId.value) {
focusedTaskId.value = activeTaskId.value
const index = uiEntries.value.findIndex(item => item.id === focusedTaskId.value)
if (focusedTaskId.value && index >= 0) {
void updateAriaActiveTask(focusedTaskId.value, index)
}
}
}

function onTreeBlur(event: FocusEvent) {
if (event.target === event.currentTarget) {
focusedTaskId.value = undefined
ariaActiveTaskId.value = undefined
selectedTaskId.value = undefined
}
}

function onTreeItemClick(taskId: string) {
focusedTaskId.value = taskId
ariaActiveTaskId.value = taskId
selectedTaskId.value = taskId
scrollerRef.value?.el?.focus()
}
</script>

<template>
Expand Down Expand Up @@ -314,21 +471,37 @@ const {
</template>
<template v-else>
<RecycleScroller
ref="scrollerRef"
class="scrolls"
flex-auto
key-field="id"
role="tree"
aria-label="Test explorer"
tabindex="0"
:aria-activedescendant="ariaActiveTaskId ? `explorer-item-${ariaActiveTaskId}` : undefined"
:item-size="28"
:items="uiEntries"
:buffer="100"
@scroll.passive="hideAllPoppers"
@blur="onTreeBlur"
@focus="onTreeFocus"
@keydown="onTreeKeydown"
>
<template #default="{ item }">
<template #default="{ item, active }">
<ExplorerItem
:id="active ? `explorer-item-${item.id}` : undefined"
class="h-28px m-0 p-0"
:task-id="item.id"
:expandable="item.expandable"
:type="item.type"
:current="activeFileId === item.id"
:role="active ? 'treeitem' : undefined"
:aria-hidden="active ? undefined : true"
:aria-level="active ? item.indent + 1 : undefined"
:aria-posinset="active ? treeItemAria.get(item.id)?.posinset : undefined"
:aria-setsize="active ? treeItemAria.get(item.id)?.setsize : undefined"
:aria-expanded="active && item.expandable ? item.expanded : undefined"
:aria-selected="active ? isSelected(item.id) : undefined"
:indent="item.indent"
:name="item.name"
:typecheck="item.typecheck === true"
Expand All @@ -339,8 +512,9 @@ const {
:slow="item.slow === true"
:opened="item.expanded"
:disable-task-location="!includeTaskLocation"
:class="selectedTest === item.id || (!selectedTest && activeFileId === item.id) ? 'bg-active' : ''"
:class="isSelected(item.id) ? 'bg-active' : ''"
:on-item-click="onItemClick"
@click="onTreeItemClick(item.id)"
/>
</template>
</RecycleScroller>
Expand Down
1 change: 1 addition & 0 deletions packages/ui/client/components/explorer/ExplorerItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ const tagsBgGradient = computed(() => {
<button
v-if="type === 'file' || type === 'suite'"
type="button"
tabindex="-1"
class="w-full h-full flex items-center"
:aria-label="`${opened ? 'Collapse' : 'Expand'} ${name}`"
@click.stop="toggleOpen"
Expand Down
59 changes: 59 additions & 0 deletions test/ui/test/ui.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,65 @@ test.describe('ui', () => {
await expect(getExplorerItem(page, 'nested suite')).toBeVisible()
})

test('navigates the explorer with keyboard', async ({ page }) => {
await page.goto(pageUrl)

const suite = getExplorerItem(page, 'suite')
const nestedSuite = getExplorerItem(page, 'nested suite')
const tree = page.getByRole('tree', { name: 'Test explorer' })
await suite.click()
await expect(tree).toBeFocused()
await expect(tree).toHaveAttribute('aria-activedescendant', await suite.getAttribute('id') as string)

await tree.press('ArrowLeft')
await expect(tree).toBeFocused()
await expect(nestedSuite).not.toBeVisible()

await tree.press('ArrowRight')
await expect(tree).toBeFocused()
await expect(nestedSuite).toBeVisible()
await tree.press('ArrowRight')
await expect(tree).toHaveAttribute('aria-activedescendant', await nestedSuite.getAttribute('id') as string)
await expect(nestedSuite).toHaveAttribute('aria-selected', 'true')

const testItem = getExplorerItem(page, 'test')
await tree.press('ArrowDown')
await expect(tree).toHaveAttribute('aria-activedescendant', await testItem.getAttribute('id') as string)
await expect(testItem).toHaveAttribute('aria-selected', 'true')
await expect(page).toHaveURL(/test=/)
await tree.press('ArrowUp')
await expect(tree).toHaveAttribute('aria-activedescendant', await nestedSuite.getAttribute('id') as string)

await tree.press('ArrowLeft')
await expect(testItem).not.toBeVisible()
await tree.press('ArrowLeft')
await expect(tree).toHaveAttribute('aria-activedescendant', await suite.getAttribute('id') as string)

await page.setViewportSize({ width: 1000, height: 500 })
await expect(getExplorerItem(page, 'zz-last-file.test.ts')).not.toBeVisible()
await tree.press('End')
await tree.press('Home')
const firstItem = getExplorerItem(page, 'aa-first-file.test.ts')
const firstItemId = await firstItem.getAttribute('id') as string
await expect(tree).toHaveAttribute('aria-activedescendant', firstItemId)

const previousItemId = await tree.getAttribute('aria-activedescendant')
await tree.press('End')
await expect.poll(() => tree.getAttribute('aria-activedescendant')).not.toBe(previousItemId)
const lastItemId = await tree.getAttribute('aria-activedescendant') as string
const lastItem = page.locator(`#${lastItemId}`)
await expect(lastItem).toBeVisible()
await expect(lastItem).toHaveAttribute('aria-selected', 'true')
await tree.press('ArrowDown')
await expect(tree).toHaveAttribute('aria-activedescendant', lastItemId)

await tree.press('Home')
await expect(tree).toHaveAttribute('aria-activedescendant', firstItemId)
await expect(firstItem).toHaveAttribute('aria-selected', 'true')
await tree.press('ArrowUp')
await expect(tree).toHaveAttribute('aria-activedescendant', firstItemId)
})

test('error', async ({ page }) => {
await page.goto(pageUrl)
await testError(page)
Expand Down
Loading