Files
upstream-tldraw/apps/examples/e2e/tests/test-page-menu.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

686 lines
22 KiB
TypeScript

import { expect, type Locator, type Page } from '@playwright/test'
import { type Editor } from 'tldraw'
import test from '../fixtures/fixtures'
import { setupOrReset, sleep } from '../shared-e2e'
declare const editor: Editor
const PAGE_MENU_ITEM_HEIGHT = 36
// The list reserves a few pixels of breathing room below the last row; the
// auto-fit and minimum heights both include it, so the e2e expectations must too.
const LIST_BOTTOM_PADDING = 4
const MIN_PAGE_MENU_LIST_HEIGHT = PAGE_MENU_ITEM_HEIGHT + LIST_BOTTOM_PADDING
const isMobileProject = () => test.info().project.name.includes('Mobile')
async function expectPageItemToBeCurrent(pageItem: Locator, isCurrent: boolean) {
await expect(pageItem).toHaveAttribute('data-iscurrent', isCurrent ? 'true' : 'false')
}
async function getPageMenuListHeight(pageList: Locator) {
return await pageList.evaluate((elm) => elm.getBoundingClientRect().height)
}
async function expectPageMenuListHeight(pageList: Locator, expectedHeight: number, tolerance = 1) {
await expect
.poll(async () => getPageMenuListHeight(pageList))
.toBeGreaterThanOrEqual(expectedHeight - tolerance)
await expect
.poll(async () => getPageMenuListHeight(pageList))
.toBeLessThanOrEqual(expectedHeight + tolerance)
}
async function dragPageMenuResizeHandle(page: Page, deltaY: number) {
const resizeHandle = page.locator('.tlui-page-menu__resize-handle')
const box = await resizeHandle.boundingBox()
if (!box) throw new Error('Could not find page menu resize handle')
const x = box.x + box.width / 2
const y = box.y + box.height / 2
await page.mouse.move(x, y)
await page.mouse.down()
await page.mouse.move(x, y + deltaY, { steps: 4 })
await page.mouse.up()
}
async function createPagesForReordering(page: Page) {
await page.evaluate(() => {
editor.createPage({ name: 'Page 2' })
editor.createPage({ name: 'Page 3' })
})
}
async function useCoarsePointer(page: Page) {
await page.evaluate(() => {
window.dispatchEvent(new PointerEvent('pointerdown', { pointerType: 'touch' }))
editor.updateInstanceState({ isCoarsePointer: true })
})
await expect.poll(() => page.evaluate(() => editor.getInstanceState().isCoarsePointer)).toBe(true)
}
async function getPageNames(page: Page) {
return await page.evaluate(() => editor.getPages().map((p) => p.name))
}
test.describe('page menu', () => {
test.beforeEach(setupOrReset)
test.afterEach(async ({ page }) => {
// press escape a few times
await page.keyboard.press('Escape')
await page.keyboard.press('Escape')
await page.keyboard.press('Escape')
})
test('you can open and close the page menu', async ({ pageMenu }) => {
const { pagemenuButton, header } = pageMenu
await expect(header).toBeHidden()
await pagemenuButton.click()
await expect(header).toBeVisible()
await pagemenuButton.click()
await expect(header).toBeHidden()
})
test('The page list height fits its rows when opened, resized, and reset', async ({
page,
pageMenu,
}) => {
test.skip(isMobileProject(), 'Desktop page menu resize behavior')
await page.evaluate(() => {
window.localStorage.removeItem('tldraw_page_menu_list_height')
editor.createPage({ name: 'Page 2' })
editor.createPage({ name: 'Page 3' })
})
await pageMenu.pagemenuButton.click()
await expect(pageMenu.pageItems).toHaveCount(3)
const autoFitHeight = PAGE_MENU_ITEM_HEIGHT * 3 + LIST_BOTTOM_PADDING
await expectPageMenuListHeight(pageMenu.pageList, autoFitHeight)
await dragPageMenuResizeHandle(page, 120)
await expect
.poll(async () => getPageMenuListHeight(pageMenu.pageList))
.toBeGreaterThan(autoFitHeight)
await dragPageMenuResizeHandle(page, -300)
await expectPageMenuListHeight(pageMenu.pageList, MIN_PAGE_MENU_LIST_HEIGHT)
await dragPageMenuResizeHandle(page, 120)
await page.locator('.tlui-page-menu__resize-handle').dblclick()
await expectPageMenuListHeight(pageMenu.pageList, autoFitHeight)
})
test('You can change pages', async ({ page, pageMenu }) => {
const { pagemenuButton, pageItems } = pageMenu
await test.step('open page menu', async () => {
await pagemenuButton.click()
await expect(pageItems.first()).toBeVisible()
})
await test.step('create a second page', async () => {
const initialCount = await pageItems.count()
await pageMenu.createButton.click()
await sleep(100)
await expect(pageItems).toHaveCount(initialCount + 1)
await page.keyboard.press('Enter')
await page.keyboard.press('Escape') // close the menu
})
await test.step('switch between pages', async () => {
await pagemenuButton.click()
await expect(pageItems).toHaveCount(2)
const firstPage = pageItems.nth(0)
const secondPage = pageItems.nth(1)
// Wait for both pages to be visible
await expect(firstPage).toBeVisible()
await expect(secondPage).toBeVisible()
await expectPageItemToBeCurrent(firstPage, false)
await expectPageItemToBeCurrent(secondPage, true)
// Click on second page
await secondPage.locator('button').first().click()
await expectPageItemToBeCurrent(firstPage, false)
await expectPageItemToBeCurrent(secondPage, true)
// Click on first page
await firstPage.locator('button').first().click()
await expectPageItemToBeCurrent(firstPage, true)
await expectPageItemToBeCurrent(secondPage, false)
})
})
test('You can create a new page by pressing enter after opening the page menu', async ({
page,
pageMenu,
}) => {
const { pagemenuButton, pageItems, createButton } = pageMenu
await test.step('open page menu and get initial count', async () => {
await pagemenuButton.click()
await expect(pageItems.first()).toBeVisible()
})
await test.step('create new page using create button', async () => {
const initialCount = await pageItems.count()
await createButton.click()
await sleep(100)
await expect(pageItems).toHaveCount(initialCount + 1)
})
await test.step('confirm page creation with enter', async () => {
await page.keyboard.press('Enter')
})
})
test('On coarse pointers, canceling the new page prompt does not create a page', async ({
page,
pageMenu,
}) => {
test.skip(!isMobileProject(), 'Coarse-pointer page menu behavior')
const { pagemenuButton, createButton } = pageMenu
await pagemenuButton.click()
await useCoarsePointer(page)
expect(await getPageNames(page)).toEqual(['Page 1'])
page.once('dialog', async (dialog) => {
expect(dialog.type()).toBe('prompt')
await dialog.dismiss()
})
await createButton.tap()
expect(await getPageNames(page)).toEqual(['Page 1'])
})
test.describe('You can rename a page', () => {
test('You can rename a page by double clicking its name', async ({ page, pageMenu }) => {
const { pagemenuButton, pageItems } = pageMenu
await test.step('open page menu', async () => {
await pagemenuButton.click()
await expect(pageItems.first()).toBeVisible()
})
await test.step('double click to rename page', async () => {
const pageItem = await pageMenu.getPageItem(0)
await pageItem.getByRole('button').first().dblclick()
// Should be in edit mode - look for input field
await expect(pageItem.locator('input')).toBeVisible()
})
await test.step('enter new name', async () => {
const input = pageItems.first().locator('input')
await expect(input).toBeVisible()
// Clear the input field more reliably
await input.clear()
await input.fill('My New Page')
await page.keyboard.press('Enter')
})
await test.step('verify page was renamed', async () => {
// Wait for the input to disappear (edit mode ends)
await expect(pageItems.first().locator('input')).toBeHidden()
// Wait a bit more for the rename to process
await sleep(200)
const pageItem = await pageMenu.getPageItem(0)
await expect(pageItem).toContainText('My New Page')
})
})
test('You can press enter to confirm a rename without closing the menu', async ({
page,
pageMenu,
}) => {
const { pagemenuButton, pageItems, header } = pageMenu
await pagemenuButton.click()
await expect(pageItems.first()).toBeVisible()
// Double click to start editing
const pageItem = await pageMenu.getPageItem(0)
await pageItem.getByRole('button').first().dblclick()
// Wait for edit mode to activate
const input = pageItem.locator('input')
await expect(input).toBeVisible()
// Clear and type new name
await input.clear()
await input.fill('Renamed Page')
await page.keyboard.press('Enter')
// Wait for edit mode to end
await expect(input).toBeHidden()
// Menu should still be open
await expect(header).toBeVisible()
// Check that the page item now contains the new name
await expect(pageItem).toContainText('Renamed Page')
})
test('You can press enter after renaming a page to close the menu', async ({
page,
pageMenu,
}) => {
const { pagemenuButton, pageItems, header } = pageMenu
await pagemenuButton.click()
await expect(pageItems.first()).toBeVisible()
// Double click to start editing
const pageItem = await pageMenu.getPageItem(0)
await pageItem.getByRole('button').first().dblclick()
// Wait for edit mode and use robust input handling
const input = pageItem.locator('input')
await expect(input).toBeVisible()
await input.clear()
await input.fill('Another Renamed Page')
await page.keyboard.press('Enter')
await page.keyboard.press('Enter')
// Menu should be closed
await expect(header).toBeHidden()
})
test('You can press escape to cancel a page rename and restore the previous name', async ({
page,
pageMenu,
}) => {
const { pagemenuButton, pageItems } = pageMenu
await pagemenuButton.click()
await expect(pageItems.first()).toBeVisible()
// Get original page name
const pageItem = await pageMenu.getPageItem(0)
const originalName = await pageItem.getByRole('button').first().textContent()
// Double click to start editing
await pageItem.getByRole('button').first().dblclick()
// Wait for edit mode and use robust input handling
const input = pageItem.locator('input')
await expect(input).toBeVisible()
await input.clear()
await input.fill('Should Not Save')
await page.keyboard.press('Escape')
// Wait for edit mode to end
await expect(input).toBeHidden()
// Should have original name back
await expect(pageItem.getByRole('button').first()).toContainText(originalName || '')
})
test('When you create a new page, the new page is focused', async ({ page, pageMenu }) => {
const { pagemenuButton, pageItems } = pageMenu
await test.step('create a new page and verify it becomes active', async () => {
await pagemenuButton.click()
const initialCount = await pageItems.count()
// Create a new page
await pageMenu.createButton.click()
await sleep(100)
// To stop editing
await page.keyboard.press('Enter')
// Verify page count increased
await expect(pageItems).toHaveCount(initialCount + 1)
// The new page (last one) should be the active/focused page
const newPageItem = await pageMenu.getPageItem(initialCount)
await expectPageItemToBeCurrent(newPageItem, true)
// All other pages should not be active
for (let i = 0; i < initialCount; i++) {
const otherPageItem = await pageMenu.getPageItem(i)
await expectPageItemToBeCurrent(otherPageItem, false)
}
})
})
test('When you open the page menu, the current page is in view', async ({ page, pageMenu }) => {
const { pagemenuButton } = pageMenu
await test.step('create multiple pages', async () => {
await pagemenuButton.click()
// Create several pages to potentially cause scrolling
for (let i = 0; i < 5; i++) {
await pageMenu.createButton.click()
await sleep(100)
await page.keyboard.press('Enter')
}
await page.keyboard.press('Escape')
})
await test.step('navigate to first page and verify it is in view', async () => {
await pagemenuButton.click()
// Click on the first page to make it current
const firstPage = await pageMenu.getPageItem(0)
await firstPage.locator('button').first().click()
// Close and reopen menu
await page.keyboard.press('Escape')
await pagemenuButton.click()
// Verify the first page (current page) is visible in the page list
await expect(firstPage).toBeInViewport()
// Also verify it's marked as checked/active
await expectPageItemToBeCurrent(firstPage, true)
})
})
test('When you open the page menu, the current page is focused', async ({ page, pageMenu }) => {
const { pagemenuButton } = pageMenu
await test.step('create multiple pages', async () => {
await pagemenuButton.click()
// Create a few pages
await pageMenu.createButton.click()
await sleep(100)
await page.keyboard.press('Enter')
await pageMenu.createButton.click()
await sleep(100)
await page.keyboard.press('Enter')
await page.keyboard.press('Escape')
})
await test.step('switch to middle page and verify focus', async () => {
await pagemenuButton.click()
// Click on the middle page to make it current
const middlePage = await pageMenu.getPageItem(1)
await middlePage.locator('button').first().click()
// Close and reopen menu
await page.keyboard.press('Escape')
await pagemenuButton.click()
// Verify the middle page is marked as the current/focused page
await expectPageItemToBeCurrent(middlePage, true)
// Verify other pages are not focused
const firstPage = await pageMenu.getPageItem(0)
const lastPage = await pageMenu.getPageItem(2)
await expectPageItemToBeCurrent(firstPage, false)
await expectPageItemToBeCurrent(lastPage, false)
})
})
})
test.describe('You can drag and drop pages to reorder them', () => {
test('Pages can be reordered by dragging the row itself', async ({ page, pageMenu }) => {
test.skip(isMobileProject(), 'Mobile page rows can only be reordered from the drag handle')
const { pagemenuButton } = pageMenu
await test.step('create multiple pages for reordering', async () => {
await createPagesForReordering(page)
})
await test.step('drag page to new position by its row', async () => {
await pagemenuButton.click()
await expect(pageMenu.pageItems).toHaveCount(3)
const firstItem = await pageMenu.getPageItem(0)
const secondItem = await pageMenu.getPageItem(1)
const firstButton = firstItem.locator('.tlui-page-menu__item__button')
await firstButton.dragTo(secondItem)
await expect.poll(() => getPageNames(page)).toEqual(['Page 2', 'Page 1', 'Page 3'])
})
})
test('On coarse pointers, pages can only be reordered by dragging the handle', async ({
page,
pageMenu,
}) => {
test.skip(!isMobileProject(), 'Coarse-pointer page menu behavior')
const { pagemenuButton } = pageMenu
await test.step('create multiple pages for reordering', async () => {
await createPagesForReordering(page)
})
await test.step('dragging the row itself does not reorder pages', async () => {
await pagemenuButton.click()
await useCoarsePointer(page)
await expect(pageMenu.pageItems).toHaveCount(3)
const firstItem = await pageMenu.getPageItem(0)
const secondItem = await pageMenu.getPageItem(1)
await expect(firstItem.getByTestId('page-menu.item-drag-handle')).toBeVisible()
await firstItem.locator('.tlui-page-menu__item__button').dragTo(secondItem)
expect(await getPageNames(page)).toEqual(['Page 1', 'Page 2', 'Page 3'])
})
await test.step('dragging the handle reorders pages', async () => {
await useCoarsePointer(page)
const firstItem = await pageMenu.getPageItem(0)
const secondItem = await pageMenu.getPageItem(1)
await firstItem.getByTestId('page-menu.item-drag-handle').dragTo(secondItem)
await expect.poll(() => getPageNames(page)).toEqual(['Page 2', 'Page 1', 'Page 3'])
})
})
})
test.describe('You can delete a page', () => {
test('You can delete a page using the submenu', async ({ page, pageMenu }) => {
const { pagemenuButton, pageItems } = pageMenu
await test.step('create an extra page so we can delete one', async () => {
await pagemenuButton.click()
await pageMenu.createButton.click()
await sleep(100)
await page.keyboard.press('Enter') // confirm rename
await page.keyboard.press('Escape') // close the menu
})
await test.step('open page menu and verify we have multiple pages', async () => {
await pagemenuButton.click()
const initialCount = await pageItems.count()
expect(initialCount).toBeGreaterThan(1)
})
await test.step('access page submenu and delete', async () => {
const pageItem = await pageMenu.getPageItem(1)
const submenuButton = pageItem.getByTestId('page-menu.item-submenu')
await submenuButton.click()
// Click delete option
const deleteButton = page.getByRole('menuitem', { name: /delete/i })
await expect(deleteButton).toBeVisible()
await deleteButton.click()
// Verify page count decreased
await expect(pageItems).toHaveCount(1)
})
})
})
test.describe('You can use the page menu', () => {
test('Right clicking a page item opens its submenu without reordering pages', async ({
page,
pageMenu,
}) => {
test.skip(isMobileProject(), 'Mobile emulation does not simulate right-click')
await createPagesForReordering(page)
await pageMenu.pagemenuButton.click()
await expect(pageMenu.pageItems).toHaveCount(3)
const firstPageItem = await pageMenu.getPageItem(0)
await firstPageItem.locator('.tlui-page-menu__item__button').click({ button: 'right' })
await expect(page.getByRole('menuitem', { name: /duplicate/i })).toBeVisible()
await expect(firstPageItem).toHaveAttribute('data-dragging', 'false')
expect(await getPageNames(page)).toEqual(['Page 1', 'Page 2', 'Page 3'])
})
test('You can duplicate a page from the page menu', async ({ page, pageMenu }) => {
const { pagemenuButton, pageItems } = pageMenu
await test.step('open page menu and get initial count', async () => {
await pagemenuButton.click()
await expect(pageItems.first()).toBeVisible()
})
await test.step('duplicate the first page', async () => {
const initialCount = await pageItems.count()
const pageItem = await pageMenu.getPageItem(0)
// Click the submenu button (three dots)
const submenuButton = pageItem.getByTestId('page-menu.item-submenu')
await submenuButton.click()
// Click duplicate option
const duplicateButton = page.getByRole('menuitem', { name: /duplicate/i })
await expect(duplicateButton).toBeVisible()
await duplicateButton.click()
// Verify page count increased
await expect(pageItems).toHaveCount(initialCount + 1)
})
})
test('You can delete a page from the page menu', async ({ page, pageMenu }) => {
const { pagemenuButton, pageItems } = pageMenu
await test.step('create an extra page so we can delete one', async () => {
await pagemenuButton.click()
await pageMenu.createButton.click()
await sleep(100)
await page.keyboard.press('Enter')
await page.keyboard.press('Escape')
})
await test.step('delete a page using submenu', async () => {
await pagemenuButton.click()
const initialCount = await pageItems.count()
expect(initialCount).toBeGreaterThan(1)
const pageItem = await pageMenu.getPageItem(1)
// Click the submenu button
const submenuButton = pageItem.getByTestId('page-menu.item-submenu')
await submenuButton.click()
// Click delete option
const deleteButton = page.getByRole('menuitem', { name: /delete/i })
await expect(deleteButton).toBeVisible()
await deleteButton.click()
// Verify page count decreased
await expect(pageItems).toHaveCount(initialCount - 1)
})
})
test('You can move a page up from the page menu', async ({ page, pageMenu }) => {
const { pagemenuButton, pageItems } = pageMenu
await test.step('create multiple pages for reordering', async () => {
await pagemenuButton.click()
// Create two additional pages (will have default names)
await pageMenu.createButton.click()
await sleep(100)
await page.keyboard.press('Enter')
await pageMenu.createButton.click()
await sleep(100)
await page.keyboard.press('Enter')
await page.keyboard.press('Escape')
})
await test.step('move the last page up', async () => {
await pagemenuButton.click()
await expect(pageItems).toHaveCount(3)
// Get the last page (index 2) and move it up
const lastPageItem = await pageMenu.getPageItem(2)
// Click the submenu button
const submenuButton = lastPageItem.getByTestId('page-menu.item-submenu')
await submenuButton.click()
// Click move up option
const moveUpButton = page.getByRole('menuitem', { name: /move up/i })
await expect(moveUpButton).toBeVisible()
await moveUpButton.click()
// Wait a moment for the reorder to complete
await sleep(200)
// Close and reopen menu to get fresh state
await page.keyboard.press('Escape')
await pagemenuButton.click()
// Verify we still have 3 pages - the operation worked
await expect(pageItems).toHaveCount(3)
})
})
test('You can move a page down from the page menu', async ({ page, pageMenu }) => {
const { pagemenuButton, pageItems } = pageMenu
await test.step('create multiple pages for reordering', async () => {
await pagemenuButton.click()
// Create two additional pages (will have default names)
await pageMenu.createButton.click()
await sleep(100)
await page.keyboard.press('Enter')
await pageMenu.createButton.click()
await sleep(100)
await page.keyboard.press('Enter')
await page.keyboard.press('Escape')
})
await test.step('move the first page down', async () => {
await pagemenuButton.click()
await expect(pageItems).toHaveCount(3)
// Get the first page (index 0) and move it down
const firstPageItem = await pageMenu.getPageItem(0)
// Click the submenu button
const submenuButton = firstPageItem.getByTestId('page-menu.item-submenu')
await submenuButton.click()
// Click move down option
const moveDownButton = page.getByRole('menuitem', { name: /move down/i })
await expect(moveDownButton).toBeVisible()
await moveDownButton.click()
// Wait a moment for the reorder to complete
await sleep(200)
// Close and reopen menu to get fresh state
await page.keyboard.press('Escape')
await pagemenuButton.click()
// Verify we still have 3 pages - the operation worked
await expect(pageItems).toHaveCount(3)
})
})
})
})