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
329 lines
11 KiB
TypeScript
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
|
|
)
|
|
})
|
|
}
|