Files
upstream-tldraw/apps/examples/e2e/tests/test-commenting-a11y.spec.ts
upstream-mirror a774ac673a
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

220 lines
8.5 KiB
TypeScript

import { Page, expect } from '@playwright/test'
import test from '../fixtures/fixtures'
// Pick the comment tool and click the canvas, then wait for the composer to take focus before
// returning. Its autofocus runs on the next animation frame, so typing straight after the click
// can land before it and be dropped — no text, nothing to post, no pin.
async function startComment(page: Page) {
await page.getByTestId('quick-actions.comment').click()
await page.mouse.click(400, 300)
await expect(
page.locator('.tlui-cmt-canvas-composer [contenteditable="true"].tlui-cmt-input')
).toBeFocused()
}
// Place a comment at the same point, post it, and dismiss the thread the send leaves open.
async function placeComment(page: Page, text: string) {
await startComment(page)
await page.keyboard.type(text)
await page.keyboard.press('Enter')
await page.keyboard.press('Escape')
}
/**
* Canvas comment markers are the only way into a thread, so they have to be reachable without a
* pointer. These tests drive the commenting example with the keyboard alone — place a comment with
* the mouse (creating one is a canvas gesture), then never touch the mouse again.
*/
test.describe('commenting a11y', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:5420/commenting/full')
await page.waitForSelector('.tl-canvas')
await page.locator('.tl-container').focus()
})
test('a comment pin is a labelled button that opens its thread from the keyboard', async ({
page,
isMobile,
}) => {
// Keyboard navigation isn't the mobile interaction model.
if (isMobile) test.skip()
// Place a comment: pick the comment tool, click the canvas, type, and send.
const commentTool = page.getByTestId('quick-actions.comment')
await commentTool.click()
await expect(commentTool).toHaveAttribute('aria-pressed', 'true')
await expect(commentTool).toHaveAttribute('data-isactive', 'true')
await page.mouse.click(400, 300)
await expect(
page.locator('.tlui-cmt-canvas-composer [contenteditable="true"].tlui-cmt-input')
).toBeFocused()
await page.keyboard.type('hello from the keyboard')
await page.keyboard.press('Enter')
// The pin exists and exposes button semantics with a name, not a bare div.
const pin = page.locator('.tlui-cmt-canvas-pin__marker')
await expect(pin).toHaveCount(1)
await expect(pin).toHaveRole('button')
await expect(pin).toHaveAttribute('aria-label', /comment by/i)
// Dismiss the thread the send left open, so the pin starts closed.
await page.keyboard.press('Escape')
await expect(pin).toHaveAttribute('aria-expanded', 'false')
// Focusing the pin and pressing Enter opens the thread — no pointer involved.
await pin.focus()
await expect(pin).toBeFocused()
await page.keyboard.press('Enter')
await expect(pin).toHaveAttribute('aria-expanded', 'true')
await expect(page.locator('.tlui-cmt-canvas-popover')).toBeVisible()
// Space toggles it back closed, as a button should.
await pin.focus()
await page.keyboard.press(' ')
await expect(pin).toHaveAttribute('aria-expanded', 'false')
})
test('the pin is reachable by tabbing, and the reply box has an accessible name', async ({
page,
isMobile,
}) => {
if (isMobile) test.skip()
await startComment(page)
// The composer is a contenteditable whose visible placeholder is aria-hidden, so without an
// explicit name a screen reader lands on an unlabelled textbox.
const composer = page.locator('.tlui-cmt-input').first()
await expect(composer).toHaveAttribute('aria-label', /.+/)
await expect(composer).toHaveRole('textbox')
await page.keyboard.type('first')
await page.keyboard.press('Enter')
const pin = page.locator('.tlui-cmt-canvas-pin__marker')
await page.keyboard.press('Escape')
// The pin participates in the tab order rather than being skipped as a div would be.
await page.locator('.tl-container').focus()
await expect
.poll(
async () => {
for (let i = 0; i < 25; i++) {
await page.keyboard.press('Tab')
if (await pin.evaluate((el) => el === document.activeElement)) return true
}
return false
},
{ timeout: 15000 }
)
.toBe(true)
})
test('a pin comes after the UI, so "move focus to canvas" keeps the first tab stop', async ({
page,
isMobile,
}) => {
if (isMobile) test.skip()
await placeComment(page, 'first')
await expect(page.locator('.tlui-cmt-canvas-pin__marker')).toHaveCount(1)
// The comments layer is portalled into the editor container, and a pin is a real button. Left
// where React drops the portal it lands ahead of the UI, taking the first tab stop from the
// skip link — which is then unreachable, and with it the keyboard route onto the canvas.
const tabOrder = await page.evaluate(() => {
const focusable = document.querySelectorAll(
'a[href],button,input,select,textarea,[tabindex]:not([tabindex="-1"]),[contenteditable="true"]'
)
return {
firstIsSkipLink: focusable[0]?.classList.contains('tl-skip-to-main-content') ?? false,
pinIndex: [...focusable].findIndex((el) =>
el.classList.contains('tlui-cmt-canvas-pin__marker')
),
count: focusable.length,
}
})
expect(tabOrder.firstIsSkipLink).toBe(true)
expect(tabOrder.pinIndex).toBe(tabOrder.count - 1)
// And tabbing into the container really does land on the skip link first.
await page.locator('.tl-container').focus()
await page.keyboard.press('Tab')
await expect(page.locator('.tl-skip-to-main-content')).toBeFocused()
})
test('leaving the edit composer hands focus back to the control that opened it', async ({
page,
isMobile,
}) => {
if (isMobile) test.skip()
await placeComment(page, 'first')
// Open the thread and spend its one Tab-into-the-reply-box, so nothing is left to pull focus
// back into the panel on its own — the state the escape has to survive.
await page.locator('.tlui-cmt-canvas-pin__marker').click()
await page.locator('.tl-container').focus()
await page.keyboard.press('Tab')
const reply = page.locator('.tlui-cmt-canvas-popover [contenteditable="true"].tlui-cmt-input')
await expect(reply).toBeFocused()
// Edit from the ⋯ menu: escaping should land back on the ⋯ button that opened it, not on the
// editor container, where Tab would walk the app's UI instead of the thread's own controls.
const moreButton = page.locator('.tlui-cmt-canvas-popover [data-cmt-more-for]')
await moreButton.click()
await page.locator('.tlui-cmt-menu-item', { hasText: 'Edit' }).click()
await expect(page.locator('.tlui-cmt-editing [contenteditable="true"]')).toBeFocused()
await page.keyboard.press('Escape')
await expect(page.locator('.tlui-cmt-canvas-popover')).toBeVisible()
await expect(moreButton).toBeFocused()
// Arrow-up-to-edit comes from the reply box, so that's where escaping returns.
await reply.focus()
await page.keyboard.press('ArrowUp')
await expect(page.locator('.tlui-cmt-editing [contenteditable="true"]')).toBeFocused()
await page.keyboard.press('Escape')
await expect(page.locator('.tlui-cmt-canvas-popover')).toBeVisible()
await expect(reply).toBeFocused()
})
test('a stacked card is keyboard-openable without nesting its body links in a button', async ({
page,
isMobile,
}) => {
if (isMobile) test.skip()
// Two comments on the same point stack behind one badge. The first carries a link, which a
// comment body renders as a real anchor.
await placeComment(page, 'see https://tldraw.dev ')
await placeComment(page, 'second')
// Open the stack list from the keyboard.
const badge = page.locator('.tlui-cmt-canvas-stack-badge')
await expect(badge).toHaveCount(1)
await badge.focus()
await page.keyboard.press('Enter')
const card = page.locator('.tlui-cmt-stack-list__card').first()
await expect(card).toBeVisible()
await expect(card.locator('a[href]')).toHaveCount(1)
// A button can't contain interactive content, so the card's link must not sit inside one —
// otherwise activation is unreliable and the control tree assistive tech sees is broken.
expect(
await page.evaluate(
() =>
![...document.querySelectorAll('.tlui-cmt-stack-list__card a[href]')].some((a) =>
a.closest('button')
)
)
).toBe(true)
// The card is still openable by keyboard, through a named button covering its surface.
const action = card.locator('.tlui-cmt-stack-list__card-action')
await expect(action).toHaveAttribute('aria-label', /comment by/i)
await action.focus()
await page.keyboard.press('Enter')
await expect(page.locator('.tlui-cmt-stack-list__thread')).toHaveCount(1)
})
})