Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,10 @@ async function openCustomFile(entry: StateEntry) {
}
</script>
<template>
<div class="ml-auto shrink-0 flex pl-1" :class="editing ? '' : 'op0 group-hover/state-row:op100'">
<div
class="ml-auto shrink-0 flex pl-1"
:class="editing ? '' : 'op0 group-hover/state-row:op100 group-focus-within/state-row:op100'"
>
<button
v-if="getCustomFile(entry) && openInEditorAvailable"
v-tooltip.bottom="'Open file'"
Expand Down
18 changes: 13 additions & 5 deletions packages/client/src/components/components/StateEntryRow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -214,12 +214,20 @@ function getStateValueClass(value: EncodedValue): string {
:style="{ paddingLeft: `${19 + depth * 15}px` }"
@click="toggleStateEntry"
>
<span
<button
v-if="hasExpandableEntry"
class="i-carbon-chevron-right flex-none text-4 op50 transition-transform"
:class="collapsed ? '' : 'rotate-90'"
aria-hidden="true"
/>
type="button"
class="h-4 w-4 flex-none flex items-center justify-center rounded-1 border-0 bg-transparent p-0 color-inherit cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary-500"
:aria-label="`Toggle ${entry.key}`"
:aria-expanded="!collapsed"
@click.stop="toggleStateEntry"
>
<span
class="i-carbon-chevron-right text-4 op50 transition-transform"
:class="collapsed ? '' : 'rotate-90'"
aria-hidden="true"
/>
</button>
<span v-else class="w-4 flex-none" />
<span
class="state-key max-w-[40%] shrink-0 overflow-hidden text-ellipsis whitespace-nowrap op70"
Expand Down
11 changes: 11 additions & 0 deletions tests/fixtures/state-entry-row/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>State entry keyboard fixture</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/main.ts"></script>
</body>
</html>
19 changes: 19 additions & 0 deletions tests/fixtures/state-entry-row/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { createApp, h } from 'vue'
import { encodeValue } from '../../../packages/kit/src'
import StateEntryRow from '../../../packages/client/src/components/components/StateEntryRow.vue'
import 'virtual:uno.css'

createApp({
render: () =>
h(StateEntryRow, {
depth: 0,
entry: {
key: 'payload',
path: ['data', 'payload'],
editable: true,
value: encodeValue({ nested: { count: 1 } }),
},
}),
})
.directive('tooltip', {})
.mount('#app')
62 changes: 60 additions & 2 deletions tests/integration/client/state-entry-row.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,50 @@ import StateEntryRow from '../../../packages/client/src/components/components/St
import { useDevtoolsClient } from '../../../packages/client/src/composables/devtools-client'

describe('StateEntryRow', () => {
it('exposes a focusable native toggle and keeps mouse and action clicks independent', async () => {
const entry: StateEntry = {
key: 'payload',
path: ['State', 'a11y-payload'],
editable: true,
value: {
kind: 'object',
name: 'Object',
entries: 1,
preview: [{ key: 'count', value: { kind: 'number', value: 1 } }],
},
}
const wrapper = mountStateEntry(entry)
const toggle = wrapper.get<HTMLButtonElement>('button[aria-expanded]')
expect(toggle.attributes('type')).toBe('button')
expect(toggle.attributes('aria-label')).toBe('Toggle payload')
expect(toggle.element.tabIndex).toBe(0)
expect(toggle.attributes('aria-expanded')).toBe('false')
expect(toggle.find('button, input, [tabindex]').exists()).toBe(false)

await toggle.trigger('click')
expect(toggle.attributes('aria-expanded')).toBe('true')
expect(wrapper.findAll('[class~="group/state-row"]')).toHaveLength(2)

const child = wrapper.findAll('[class~="group/state-row"]')[1]!
await child.get('[aria-label="Edit state value"]').trigger('click')
expect(child.find('input').exists()).toBe(true)
expect(wrapper.find('button button, button input, [role="button"] button').exists()).toBe(false)
await child.get('[aria-label="Cancel state value edit"]').trigger('click')
expect(child.find('input').exists()).toBe(false)
expect(toggle.attributes('aria-expanded')).toBe('true')

await wrapper.get('[aria-label="Copy state path"]').trigger('click')
expect(toggle.attributes('aria-expanded')).toBe('true')
await toggle.trigger('click')
expect(toggle.attributes('aria-expanded')).toBe('false')
await wrapper.get('[class~="group/state-row"]').trigger('click')
expect(toggle.attributes('aria-expanded')).toBe('true')

await wrapper.setProps({ entry: { ...entry, value: { kind: 'number', value: 2 } } })
expect(wrapper.find('[aria-expanded]').exists()).toBe(false)
wrapper.unmount()
})

it.each([
['Object', { kind: 'object', name: 'Object', entries: 0, preview: [], handle: 'empty-object' }],
['Array', { kind: 'array', length: 0, preview: [], handle: 'empty-array' }],
Expand All @@ -36,6 +80,7 @@ describe('StateEntryRow', () => {
})

expect(wrapper.find('.i-carbon-chevron-right').exists()).toBe(false)
expect(wrapper.find('[aria-expanded]').exists()).toBe(false)

await wrapper.find('[class~="group/state-row"]').trigger('click')
expect(query).not.toHaveBeenCalled()
Expand All @@ -44,7 +89,7 @@ describe('StateEntryRow', () => {
},
)

it('keeps the expand icon for a non-empty value awaiting lazy children', async () => {
it('announces lazy expansion and reuses cached children when toggled again', async () => {
const wrapper = mountStateEntry({
key: 'lazy',
path: ['State', 'lazy'],
Expand All @@ -70,10 +115,13 @@ describe('StateEntryRow', () => {
})

expect(wrapper.find('.i-carbon-chevron-right').exists()).toBe(true)
const toggle = wrapper.get('button[aria-expanded]')
expect(toggle.attributes('aria-expanded')).toBe('false')

await wrapper.find('[class~="group/state-row"]').trigger('click')
await toggle.trigger('click')
await flushPromises()

expect(toggle.attributes('aria-expanded')).toBe('true')
expect(query).toHaveBeenCalledWith(
expect.objectContaining({
type: 'values:expand',
Expand All @@ -82,6 +130,15 @@ describe('StateEntryRow', () => {
)
expect(wrapper.text()).toContain('child')

await toggle.trigger('click')
expect(toggle.attributes('aria-expanded')).toBe('false')
expect(wrapper.findAll('[class~="group/state-row"]')).toHaveLength(1)
await toggle.trigger('click')
await flushPromises()
expect(toggle.attributes('aria-expanded')).toBe('true')
expect(wrapper.text()).toContain('child')
expect(query).toHaveBeenCalledTimes(1)

wrapper.unmount()
})

Expand Down Expand Up @@ -120,6 +177,7 @@ describe('StateEntryRow', () => {
await flushPromises()
expect(wrapper.text()).toContain('new-child')
expect(wrapper.text()).not.toContain('old-child')
expect(wrapper.get('button[aria-expanded]').attributes('aria-expanded')).toBe('true')
wrapper.unmount()
})

Expand Down
123 changes: 123 additions & 0 deletions tests/smoke/state-entry-row.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import type { Browser } from 'playwright'
import type { ViteDevServer } from 'vite'
import { chromium } from 'playwright'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { createServer } from 'vite'
import vue from '@vitejs/plugin-vue'
import { createRequire } from 'node:module'
import unoConfig from '../../packages/client/uno.config'

const clientRequire = createRequire(new URL('../../packages/client/package.json', import.meta.url))
const { default: UnoCSS } = await import(clientRequire.resolve('unocss/vite'))

const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..')

describe('StateEntryRow keyboard accessibility', () => {
let browser: Browser | undefined
let server: ViteDevServer | undefined

beforeAll(async () => {
server = await createServer({
configFile: false,
root: resolve(root, 'tests/fixtures/state-entry-row'),
plugins: [vue(), UnoCSS(unoConfig)],
resolve: {
alias: [
{
find: '@vue/devtools-kit/client',
replacement: resolve(root, 'packages/kit/src/client.ts'),
},
{ find: '@vue/devtools-kit', replacement: resolve(root, 'packages/kit/src/index.ts') },
],
},
server: { host: '127.0.0.1', port: 0 },
})
await server.listen()
browser = await chromium.launch({
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
headless: true,
})
})

afterAll(async () => {
await browser?.close()
await server?.close()
})

it.each(['light', 'dark'])(
'toggles with the keyboard in the %s theme without activating ancestor rows',
async (theme) => {
const page = await browser!.newPage({ permissions: ['clipboard-read', 'clipboard-write'] })
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
try {
await page.goto(server!.resolvedUrls!.local[0]!)
const parent = page.getByRole('button', { name: 'Toggle payload', exact: true })
await parent.waitFor({ timeout: 5000 })
await page
.locator('html')
.evaluate((node, theme) => node.classList.toggle('dark', theme === 'dark'), theme)
await page.keyboard.press('Tab')
expect(await parent.evaluate((node) => document.activeElement === node)).toBe(true)
expect(await parent.getAttribute('aria-expanded')).toBe('false')
expect(await parent.evaluate((node) => getComputedStyle(node).outlineStyle)).not.toBe(
'none',
)

await page.keyboard.press('Enter')
const child = page.getByRole('button', { name: 'Toggle nested', exact: true })
await child.waitFor()
expect(await parent.getAttribute('aria-expanded')).toBe('true')
await page.keyboard.press('Space')
expect(await parent.getAttribute('aria-expanded')).toBe('false')
expect(await child.count()).toBe(0)
await page.keyboard.press('Space')
await child.waitFor()
expect(await parent.getAttribute('aria-expanded')).toBe('true')

await page.keyboard.press('Tab')
expect(
await page
.getByRole('button', { name: 'Add child state value' })
.first()
.evaluate((node) => document.activeElement === node),
).toBe(true)
// Parent actions are siblings of its toggle and remain reachable and visible.
const copyPath = page.getByRole('button', { name: 'Copy state path', exact: true }).first()
await copyPath.focus()
expect(
await copyPath.evaluate((node) => getComputedStyle(node.parentElement!).opacity),
).toBe('1')
await page.keyboard.press('Enter')
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe('data.payload')
expect(await parent.getAttribute('aria-expanded')).toBe('true')

await child.focus()
await page.keyboard.press('Enter')
expect(await child.getAttribute('aria-expanded')).toBe('true')
expect(await parent.getAttribute('aria-expanded')).toBe('true')
const edit = page.getByRole('button', { name: 'Edit state value', exact: true })
await edit.focus()
await page.keyboard.press('Space')
await page.getByRole('textbox').waitFor()
expect(
await page.locator('button button, button input, [role="button"] button').count(),
).toBe(0)
await page.getByRole('button', { name: 'Cancel state value edit' }).focus()
await page.keyboard.press('Enter')
expect(await page.getByRole('textbox').count()).toBe(0)
expect(await parent.getAttribute('aria-expanded')).toBe('true')
expect(await child.getAttribute('aria-expanded')).toBe('true')
await child.focus()
await page.keyboard.press('Space')
expect(await child.getAttribute('aria-expanded')).toBe('false')
expect(await parent.getAttribute('aria-expanded')).toBe('true')
expect(errors).toEqual([])
} finally {
await page.close()
}
},
)
})