Files
upstream-tldraw/apps/examples/e2e/tests/test-a11y-style-panel-navigation.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

329 lines
11 KiB
TypeScript

import { Page, expect } from '@playwright/test'
import { Editor } from 'tldraw'
import test from '../fixtures/fixtures'
import { setupPage } from '../shared-e2e'
declare const editor: Editor
test.describe('A11y Style Panel Navigation', () => {
test.beforeEach(async ({ page }) => {
await setupPage(page)
})
test('can navigate between geo shape label editing and style panel with keyboard', async ({
page,
isMobile,
}) => {
if (isMobile) {
// Skip on mobile as the keyboard navigation behavior is different
test.skip()
}
// Create a geo shape
await page.keyboard.press('r') // Rectangle tool
await page.mouse.click(200, 200)
await page.keyboard.press('v') // Select tool
// Verify we have a geo shape selected
const selectedShapes = await page.evaluate(() => editor.getSelectedShapes())
expect(selectedShapes).toHaveLength(1)
expect(selectedShapes[0].type).toBe('geo')
// Step 1: Press Enter to start editing the label
await page.keyboard.press('Enter')
// Verify we're in editing mode
const isEditingShape = await page.evaluate(() => editor.getEditingShapeId() !== null)
expect(isEditingShape).toBe(true)
await expect.poll(() => isFocusedOnRichTextEditor(page)).toBe(true)
// Type some text
await page.keyboard.type('Test Label')
// Step 2: Press Escape to exit editing mode
await page.keyboard.press('Escape')
// Verify we're no longer editing
const isStillEditing = await page.evaluate(() => editor.getEditingShapeId() !== null)
expect(isStillEditing).toBe(false)
// Step 3: Press Cmd+Enter to focus the style panel
await page.keyboard.press('Meta+Enter')
// Verify that the first button in the style panel is focused
await expect
.poll(() =>
page.evaluate(() => {
const activeEl = document.activeElement
return activeEl?.closest('.tlui-style-panel') !== null && activeEl?.tagName === 'BUTTON'
})
)
.toBe(true)
// Step 4: Use arrow keys to navigate to a different color (assuming color buttons are first)
await page.keyboard.press('ArrowRight')
// Verify focus has moved to the next button
await expect
.poll(() =>
page.evaluate(() => {
const activeEl = document.activeElement
return activeEl?.tagName === 'BUTTON' && activeEl?.closest('.tlui-style-panel') !== null
})
)
.toBe(true)
// Step 5: Press Enter to select the color
await page.keyboard.press('Enter')
// Step 6: Press Escape to return focus to the shape. The Escape keydown
// is what hands focus back to the canvas, and under load that single
// keypress can be dropped, so re-send it until focus actually settles.
await pressUntilFocusedOnCanvas(page)
// Step 7: Press Enter again to edit the label. Selecting a colour above makes
// the style panel re-render, and its roving-tabindex toolbar can bounce focus
// back onto the selected radio asynchronously — after Escape already returned
// focus to the canvas. So drive editing through whatever focus state we land
// in: Escape back to the canvas if focus bounced into the panel, otherwise
// Enter to start editing. The label text is fully replaced below, so a stray
// newline from a repeated Enter can't affect the result.
await startEditingSelectedShapeViaKeyboard(page)
// The rich text editor focuses its contenteditable on a deferred timeout, so
// wait for it to be the active element before typing or the first keystrokes
// get dropped (matching the wait used when first entering editing above).
await expect.poll(() => isFocusedOnRichTextEditor(page)).toBe(true)
// Replace the label text. Even once the contenteditable is focused, the
// editor's async setup can still drop the leading keystrokes, so select-all,
// type, and verify — retrying the whole select-and-replace until it sticks
// rather than asserting a single racy attempt.
await replaceSelectedShapeLabel(page, 'Updated Label')
// Exit editing
await page.keyboard.press('Escape')
})
// Skipped: the cmd+enter keyboard event isn't reliably picked up while editing rich text in the
// test harness, so the body passes intermittently. Because it was marked `test.fail`, those
// intermittent passes were reported as failures and flaked the suite. The behavior works in
// practice; re-enable once the event dispatch can be made deterministic (a reliable way to send
// the shortcut to the focused contenteditable).
test.skip('in a geo shape, cmd+enter returns focus to the canvas', async ({ page, isMobile }) => {
if (isMobile) {
test.skip()
}
// Create a geo shape
await page.keyboard.press('r') // Rectangle tool
await page.mouse.click(200, 200)
await page.keyboard.press('v') // Select tool
// press enter
await page.keyboard.press('Enter')
await expect.poll(() => isFocusedOnRichTextEditor(page)).toBe(true)
// expect editor to be in the editing state
expect(await page.evaluate(() => editor.isIn('select.editing_shape'))).toBe(true)
// dispatch cmd+enter to the active element
await page.keyboard.press('ControlOrMeta+Enter')
await page.waitForTimeout(100)
expect(await page.evaluate(() => editor.isIn('select.editing_shape'))).toBe(false)
await expect.poll(() => isFocusedOnCanvas(page)).toBe(true)
})
test('in a note shape, cmd+enter creates the next shape and does not focus on the style panel', async ({
page,
isMobile,
}) => {
if (isMobile) {
test.skip()
}
// Create a note shape
await page.keyboard.press('n') // Note tool
await page.mouse.click(200, 200)
await page.waitForTimeout(200)
await expect.poll(() => isFocusedOnRichTextEditor(page)).toBe(true)
const firstShapeId = await page.evaluate(() => {
return editor.getOnlySelectedShape()?.id
})
expect(firstShapeId).toBeDefined()
// Press cmd+enter
await page.keyboard.press('Meta+Enter')
// Should be on a new shape now
const finalShapeId = await page.evaluate(() => {
return editor.getOnlySelectedShape()?.id
})
expect(finalShapeId).toBeDefined()
expect(finalShapeId).not.toBe(firstShapeId)
await expect.poll(() => isFocusedOnStylePanel(page)).toBe(false)
await expect.poll(() => isFocusedOnCanvas(page)).toBe(false)
await expect.poll(() => isFocusedOnRichTextEditor(page)).toBe(true)
// Press escape
await pressUntilFocusedOnCanvas(page)
})
test('style panel focus works with different geo shapes', async ({ page, isMobile }) => {
if (isMobile) {
test.skip()
}
// Test with ellipse
await page.keyboard.press('o') // Ellipse tool
await page.mouse.click(300, 300)
await page.keyboard.press('v') // Select tool
// Verify we can focus style panel
await page.keyboard.press('Meta+Enter')
await expect.poll(() => isFocusedOnStylePanel(page)).toBe(true)
// Return to shape and test editing
await page.keyboard.press('Escape')
await page.keyboard.press('Enter')
const isEditing = await page.evaluate(() => editor.getEditingShapeId() !== null)
expect(isEditing).toBe(true)
})
test('style panel focus works with cmd+enter shortcut', async ({ page, isMobile }) => {
if (isMobile) {
test.skip()
}
// Create a geo shape
await page.keyboard.press('r') // Rectangle tool
await page.mouse.click(200, 200)
await page.keyboard.press('v') // Select tool
await expect.poll(() => isFocusedOnCanvas(page)).toBe(true)
// Test that cmd+enter focuses the style panel even without editing first
await page.keyboard.press('Meta+Enter')
await expect.poll(() => isFocusedOnStylePanel(page)).toBe(true)
// Navigate in the style panel
await page.keyboard.press('ArrowRight')
// Verify focus is still in style panel
await expect.poll(() => isFocusedOnStylePanel(page)).toBe(true)
// Return to canvas. The Escape keydown is what restores focus to the
// canvas, and that single keypress can be dropped under load, so re-send
// it until focus actually settles instead of asserting once.
await pressUntilFocusedOnCanvas(page)
})
})
// Escape returns focus to the canvas from wherever it currently is — the style
// panel (see DefaultStylePanel's keydown handler, which calls
// editor.getContainer().focus() synchronously and stops propagation) or a shape's
// rich text editor (Escape exits editing). The transition is instant, so when focus
// fails to settle it's because the single Escape keypress was dropped under load
// rather than because it was slow — so re-send it until focus returns to the canvas.
// The early return means we never press while already on the canvas, so a stray
// repeat can't deselect the shape.
async function pressUntilFocusedOnCanvas(page: Page) {
await expect
.poll(
async () => {
if (await isFocusedOnCanvas(page)) return true
await page.keyboard.press('Escape')
return isFocusedOnCanvas(page)
},
{ timeout: 5000, intervals: [100, 200, 300, 500] }
)
.toBe(true)
}
// Start editing the selected shape's label with the keyboard, tolerating both a
// dropped keypress and the style panel's roving-tabindex focus bounce. Each round:
// if focus has landed back in the style panel, Escape returns it to the canvas;
// otherwise Enter starts editing the selected shape. We only press Enter while not
// already editing, so we never inject a stray newline into an active label.
async function startEditingSelectedShapeViaKeyboard(page: Page) {
const isEditing = () => page.evaluate(() => editor.getEditingShapeId() !== null)
await expect
.poll(
async () => {
if (await isEditing()) return true
const isInStylePanel = await page.evaluate(
() => document.activeElement?.closest('.tlui-style-panel') !== null
)
await page.keyboard.press(isInStylePanel ? 'Escape' : 'Enter')
return isEditing()
},
{ timeout: 8000, intervals: [100, 200, 300, 500] }
)
.toBe(true)
}
// Select all of the editing shape's label text and replace it, tolerating dropped
// keystrokes during the editor's async setup. Each attempt re-selects everything
// and retypes, so a partially-typed result from a previous attempt is fully
// overwritten rather than appended to — the poll converges once nothing drops.
// The select-all must be ControlOrMeta: plain Meta+a selects nothing on Linux, so
// every retry would append another copy instead of replacing.
async function replaceSelectedShapeLabel(page: Page, text: string) {
const getText = () =>
page.evaluate(() => {
const shape = editor.getSelectedShapes()[0] as any
return editor.getShapeUtil(shape).getText(shape)
})
await expect
.poll(
async () => {
await page.keyboard.press('ControlOrMeta+a')
await page.keyboard.type(text)
return getText()
},
{ timeout: 8000, intervals: [100, 200, 300, 500] }
)
.toBe(text)
}
async function isFocusedOnStylePanel(page: Page) {
return await page.evaluate(() => {
const activeEl = document.activeElement
return (
// check that the active element is within both the editor container and the style panel
activeEl?.role === 'radio' && activeEl?.closest('.tlui-style-panel') !== null
)
})
}
async function isFocusedOnRichTextEditor(page: Page) {
return await page.evaluate(() => {
const activeEl = document.activeElement
return (
activeEl?.role === 'textbox' &&
activeEl?.closest('.tl-container') !== null &&
activeEl?.closest('.tlui-style-panel') === null
)
})
}
async function isFocusedOnCanvas(page: Page) {
return await page.evaluate(() => {
const activeEl = document.activeElement
return (
activeEl?.role === 'application' &&
activeEl?.closest('.tl-container') !== null &&
activeEl?.closest('.tlui-style-panel') === null
)
})
}