Skip to content

Commit ddd841e

Browse files
authored
feat(tldraw): redesign page menu (tldraw#8836)
This is a clean-history reimplementation of tldraw#8827 (branch [`steve/page-menu-no-edit-mode`](https://github.com/tldraw/tldraw/tree/steve/page-menu-no-edit-mode)) — the final diff is byte-identical, but the work is split into three reviewable commits instead of 24 incremental updates. Please review here; tldraw#8827 has been closed in favor of this PR. The page menu used to require flipping into an explicit edit mode to rename pages or reorder them. This PR removes that mode entirely. Reordering happens by dragging the row itself, renaming happens inline (double-click, Enter, or the row submenu), and the popover gains a draggable resize handle so the list height can be adjusted and persisted across sessions. https://github.com/user-attachments/assets/c9e0a4fc-e644-4134-b261-58c0515cf4a2 Closes tldraw#8814. ### Interactions | Interaction | Before | After | | ------------------ | ------------------------------------------ | ------------------------------------------- | | Reorder a page | Toggle Edit, drag the dedicated handle | Drag the row itself | | Rename a page | Toggle Edit, click the field | Double-click the row, press Enter, or use the kebab submenu | | Current page mark | Checkmark icon | Subtle background pill (matches dotcom sidebar) | | Submenu trigger | Always visible | Hidden until row hover (and hidden while renaming or dragging) | | List height | Fixed | Drag the resize handle to adjust; double-click it to reset to default. Persisted to localStorage. | | Create page button | Header | Pinned to the bottom of the popover | Renaming a non-current page no longer silently navigates to it. The rename input matches the row's vertical metrics so the label doesn't shift when editing, and blur commits the change. ### Commit storyline 1. **`feat(tldraw): redesign page menu`** — drop the edit-mode toggle, switch to inline rename, move "Create new page" to a footer button, replace the checkmark with a current-row pill, and add the auto-scrolling drag-from-row reorder behavior (plus the coarse-pointer drag handle). 2. **`feat(tldraw): make page menu list resizable and persist height`** — add the resize handle, localStorage persistence, double-click to reset, and the Radix-available-height cap. 3. **`test(e2e): extend overlay snapshot timeout for cold preview runs`** — small unrelated stability tweak that the original branch picked up along the way. ### Change type - [x] `improvement` ### Test plan 1. Open the page menu with multiple pages. 2. Drag a row by its body — it should reorder without entering any mode. 3. Double-click a row's label (or press Enter / use the kebab menu) — it should rename inline. Blur should commit. 4. Confirm the current page shows the highlight pill and renaming a non-current page does not navigate. 5. Drag the resize handle at the bottom of the popover up and down; reopen the menu and confirm the height is remembered. 6. Double-click the resize handle — the list should snap back to its default height and forget the saved preference. 7. Open the page menu when the current page is far down the list — it should scroll into view. - [x] End to end tests ### Release notes - Redesigned the page menu: reorder rows by dragging them directly, rename inline by double-clicking or pressing Enter, and resize the page list to remember your preferred height (double-click the resize handle to reset it). The explicit edit mode has been removed. ### API changes - Added `page-menu.resize` translation key for the resize handle's accessible label. - Removed unused `page-menu.max-page-count-reached` translation key. ### Code changes | Section | LOC change | | --------------- | ------------ | | Core code | +663 / -498 | | Tests | +115 / -69 | | Automated files | +1 / -1 |
1 parent 3748941 commit ddd841e

12 files changed

Lines changed: 779 additions & 568 deletions

File tree

apps/examples/e2e/fixtures/menus/PageMenu.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,15 @@ export class PageMenu {
44
readonly pagemenuButton: Locator
55
readonly header: Locator
66
readonly createButton: Locator
7-
readonly editButton: Locator
87
readonly pageList: Locator
98
readonly pageItems: Locator
109

1110
constructor(public readonly page: Page) {
1211
this.page = page
1312
this.pagemenuButton = this.page.getByTestId('page-menu.button')
14-
this.header = this.page.getByRole('dialog').getByText('Pages')
13+
// The popover no longer has a title row — use the list as the "is the menu open" anchor.
14+
this.header = this.page.getByTestId('page-menu.list')
1515
this.createButton = this.page.getByTestId('page-menu.create')
16-
this.editButton = this.page.getByTestId('page-menu.edit')
1716
this.pageList = this.page.getByTestId('page-menu.list')
1817
this.pageItems = this.page.getByTestId('page-menu.item')
1918
}

apps/examples/e2e/tests/test-overlay-snapshots.spec.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,12 @@ async function snapshotCanvas(page: Page) {
3939
// Allow a small tolerance to absorb sub-pixel anti-aliasing variance on
4040
// rotated or curved overlay strokes. A real overlay regression (missing
4141
// handle, wrong color, moved stroke) changes far more than 0.5% of pixels.
42-
await expect(page.locator('.tl-canvas')).toHaveScreenshot({ maxDiffPixelRatio: 0.005 })
42+
// Screenshot capture also waits for the canvas element to become stable,
43+
// which can exceed the global 2s expect timeout on cold preview runs.
44+
await expect(page.locator('.tl-canvas')).toHaveScreenshot({
45+
maxDiffPixelRatio: 0.005,
46+
timeout: 10_000,
47+
})
4348
}
4449

4550
test.describe('Overlay snapshots', () => {

apps/examples/e2e/tests/test-page-menu.spec.ts

Lines changed: 107 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,35 @@
1-
import { expect } from '@playwright/test'
1+
import { expect, type Locator, type Page } from '@playwright/test'
2+
import { type Editor } from 'tldraw'
23
import test from '../fixtures/fixtures'
34
import { setupOrReset, sleep } from '../shared-e2e'
45

6+
declare const editor: Editor
7+
8+
const isMobileProject = () => test.info().project.name.includes('Mobile')
9+
10+
async function expectPageItemToBeCurrent(pageItem: Locator, isCurrent: boolean) {
11+
await expect(pageItem).toHaveAttribute('data-iscurrent', isCurrent ? 'true' : 'false')
12+
}
13+
14+
async function createPagesForReordering(page: Page) {
15+
await page.evaluate(() => {
16+
editor.createPage({ name: 'Page 2' })
17+
editor.createPage({ name: 'Page 3' })
18+
})
19+
}
20+
21+
async function useCoarsePointer(page: Page) {
22+
await page.evaluate(() => {
23+
window.dispatchEvent(new PointerEvent('pointerdown', { pointerType: 'touch' }))
24+
editor.updateInstanceState({ isCoarsePointer: true })
25+
})
26+
await expect.poll(() => page.evaluate(() => editor.getInstanceState().isCoarsePointer)).toBe(true)
27+
}
28+
29+
async function getPageNames(page: Page) {
30+
return await page.evaluate(() => editor.getPages().map((p) => p.name))
31+
}
32+
533
test.describe('page menu', () => {
634
test.beforeEach(setupOrReset)
735

@@ -49,31 +77,19 @@ test.describe('page menu', () => {
4977
await expect(firstPage).toBeVisible()
5078
await expect(secondPage).toBeVisible()
5179

52-
await expect(
53-
firstPage.locator('.tlui-page-menu__item__button > .tlui-button__icon')
54-
).toHaveAttribute('data-checked', 'false')
55-
await expect(
56-
secondPage.locator('.tlui-page-menu__item__button > .tlui-button__icon')
57-
).toHaveAttribute('data-checked', 'true')
80+
await expectPageItemToBeCurrent(firstPage, false)
81+
await expectPageItemToBeCurrent(secondPage, true)
5882

5983
// Click on second page
6084
await secondPage.locator('button').first().click()
61-
await expect(
62-
firstPage.locator('.tlui-page-menu__item__button > .tlui-button__icon')
63-
).toHaveAttribute('data-checked', 'false')
64-
await expect(
65-
secondPage.locator('.tlui-page-menu__item__button > .tlui-button__icon')
66-
).toHaveAttribute('data-checked', 'true')
85+
await expectPageItemToBeCurrent(firstPage, false)
86+
await expectPageItemToBeCurrent(secondPage, true)
6787

6888
// Click on first page
6989
await firstPage.locator('button').first().click()
7090

71-
await expect(
72-
firstPage.locator('.tlui-page-menu__item__button > .tlui-button__icon')
73-
).toHaveAttribute('data-checked', 'true')
74-
await expect(
75-
secondPage.locator('.tlui-page-menu__item__button > .tlui-button__icon')
76-
).toHaveAttribute('data-checked', 'false')
91+
await expectPageItemToBeCurrent(firstPage, true)
92+
await expectPageItemToBeCurrent(secondPage, false)
7793
})
7894
})
7995

@@ -100,6 +116,27 @@ test.describe('page menu', () => {
100116
})
101117
})
102118

119+
test('On coarse pointers, canceling the new page prompt does not create a page', async ({
120+
page,
121+
pageMenu,
122+
}) => {
123+
test.skip(!isMobileProject(), 'Coarse-pointer page menu behavior')
124+
125+
const { pagemenuButton, createButton } = pageMenu
126+
127+
await pagemenuButton.click()
128+
await useCoarsePointer(page)
129+
expect(await getPageNames(page)).toEqual(['Page 1'])
130+
131+
page.once('dialog', async (dialog) => {
132+
expect(dialog.type()).toBe('prompt')
133+
await dialog.dismiss()
134+
})
135+
await createButton.tap()
136+
137+
expect(await getPageNames(page)).toEqual(['Page 1'])
138+
})
139+
103140
test.describe('You can rename a page', () => {
104141
test('You can rename a page by double clicking its name', async ({ page, pageMenu }) => {
105142
const { pagemenuButton, pageItems } = pageMenu
@@ -246,16 +283,12 @@ test.describe('page menu', () => {
246283

247284
// The new page (last one) should be the active/focused page
248285
const newPageItem = await pageMenu.getPageItem(initialCount)
249-
await expect(
250-
newPageItem.locator('.tlui-page-menu__item__button > .tlui-button__icon')
251-
).toHaveAttribute('data-checked', 'true')
286+
await expectPageItemToBeCurrent(newPageItem, true)
252287

253288
// All other pages should not be active
254289
for (let i = 0; i < initialCount; i++) {
255290
const otherPageItem = await pageMenu.getPageItem(i)
256-
await expect(
257-
otherPageItem.locator('.tlui-page-menu__item__button > .tlui-button__icon')
258-
).toHaveAttribute('data-checked', 'false')
291+
await expectPageItemToBeCurrent(otherPageItem, false)
259292
}
260293
})
261294
})
@@ -289,9 +322,7 @@ test.describe('page menu', () => {
289322
await expect(firstPage).toBeInViewport()
290323

291324
// Also verify it's marked as checked/active
292-
await expect(
293-
firstPage.locator('.tlui-page-menu__item__button > .tlui-button__icon')
294-
).toHaveAttribute('data-checked', 'true')
325+
await expectPageItemToBeCurrent(firstPage, true)
295326
})
296327
})
297328

@@ -322,59 +353,70 @@ test.describe('page menu', () => {
322353
await pagemenuButton.click()
323354

324355
// Verify the middle page is marked as the current/focused page
325-
await expect(
326-
middlePage.locator('.tlui-page-menu__item__button > .tlui-button__icon')
327-
).toHaveAttribute('data-checked', 'true')
356+
await expectPageItemToBeCurrent(middlePage, true)
328357

329358
// Verify other pages are not focused
330359
const firstPage = await pageMenu.getPageItem(0)
331360
const lastPage = await pageMenu.getPageItem(2)
332-
await expect(
333-
firstPage.locator('.tlui-page-menu__item__button > .tlui-button__icon')
334-
).toHaveAttribute('data-checked', 'false')
335-
await expect(
336-
lastPage.locator('.tlui-page-menu__item__button > .tlui-button__icon')
337-
).toHaveAttribute('data-checked', 'false')
361+
await expectPageItemToBeCurrent(firstPage, false)
362+
await expectPageItemToBeCurrent(lastPage, false)
338363
})
339364
})
340365
})
341366

342367
test.describe('You can drag and drop pages to reorder them', () => {
343-
test('You can enter drag and drop mode and drag a page to a new position', async ({
368+
test('Pages can be reordered by dragging the row itself', async ({ page, pageMenu }) => {
369+
test.skip(isMobileProject(), 'Mobile page rows can only be reordered from the drag handle')
370+
371+
const { pagemenuButton } = pageMenu
372+
373+
await test.step('create multiple pages for reordering', async () => {
374+
await createPagesForReordering(page)
375+
})
376+
377+
await test.step('drag page to new position by its row', async () => {
378+
await pagemenuButton.click()
379+
await expect(pageMenu.pageItems).toHaveCount(3)
380+
const firstItem = await pageMenu.getPageItem(0)
381+
const secondItem = await pageMenu.getPageItem(1)
382+
const firstButton = firstItem.locator('.tlui-page-menu__item__button')
383+
384+
await firstButton.dragTo(secondItem)
385+
await expect.poll(() => getPageNames(page)).toEqual(['Page 2', 'Page 1', 'Page 3'])
386+
})
387+
})
388+
389+
test('On coarse pointers, pages can only be reordered by dragging the handle', async ({
344390
page,
345391
pageMenu,
346392
}) => {
347-
const { pagemenuButton, editButton } = pageMenu
393+
test.skip(!isMobileProject(), 'Coarse-pointer page menu behavior')
394+
395+
const { pagemenuButton } = pageMenu
348396

349397
await test.step('create multiple pages for reordering', async () => {
350-
await pagemenuButton.click()
351-
await pageMenu.createButton.click()
352-
await sleep(100)
353-
await page.keyboard.press('Enter')
354-
await pageMenu.createButton.click()
355-
await sleep(100)
356-
await page.keyboard.press('Enter')
357-
await page.keyboard.press('Escape')
398+
await createPagesForReordering(page)
358399
})
359400

360-
await test.step('enter edit mode', async () => {
401+
await test.step('dragging the row itself does not reorder pages', async () => {
361402
await pagemenuButton.click()
362-
await editButton.click()
363-
// Should see drag handles
364-
await expect(page.locator('.tlui-page_menu__item__sortable__handle').first()).toBeVisible()
365-
})
366-
367-
await test.step('drag page to new position', async () => {
368-
const dragHandle = page.locator('.tlui-page_menu__item__sortable__handle').first()
403+
await useCoarsePointer(page)
404+
await expect(pageMenu.pageItems).toHaveCount(3)
405+
const firstItem = await pageMenu.getPageItem(0)
369406
const secondItem = await pageMenu.getPageItem(1)
370407

371-
// Perform drag operation
372-
await dragHandle.dragTo(secondItem)
408+
await expect(firstItem.getByTestId('page-menu.item-drag-handle')).toBeVisible()
409+
await firstItem.locator('.tlui-page-menu__item__button').dragTo(secondItem)
410+
expect(await getPageNames(page)).toEqual(['Page 1', 'Page 2', 'Page 3'])
373411
})
374412

375-
await test.step('exit edit mode', async () => {
376-
await editButton.click()
377-
await expect(page.locator('.tlui-page_menu__item__sortable__handle').first()).toBeHidden()
413+
await test.step('dragging the handle reorders pages', async () => {
414+
await useCoarsePointer(page)
415+
const firstItem = await pageMenu.getPageItem(0)
416+
const secondItem = await pageMenu.getPageItem(1)
417+
418+
await firstItem.getByTestId('page-menu.item-drag-handle').dragTo(secondItem)
419+
await expect.poll(() => getPageNames(page)).toEqual(['Page 2', 'Page 1', 'Page 3'])
378420
})
379421
})
380422
})
@@ -399,7 +441,7 @@ test.describe('page menu', () => {
399441

400442
await test.step('access page submenu and delete', async () => {
401443
const pageItem = await pageMenu.getPageItem(1)
402-
const submenuButton = pageItem.locator('.tlui-page_menu__item__submenu button')
444+
const submenuButton = pageItem.getByTestId('page-menu.item-submenu')
403445
await submenuButton.click()
404446

405447
// Click delete option
@@ -427,7 +469,7 @@ test.describe('page menu', () => {
427469
const pageItem = await pageMenu.getPageItem(0)
428470

429471
// Click the submenu button (three dots)
430-
const submenuButton = pageItem.locator('.tlui-page_menu__item__submenu button')
472+
const submenuButton = pageItem.getByTestId('page-menu.item-submenu')
431473
await submenuButton.click()
432474

433475
// Click duplicate option
@@ -459,7 +501,7 @@ test.describe('page menu', () => {
459501
const pageItem = await pageMenu.getPageItem(1)
460502

461503
// Click the submenu button
462-
const submenuButton = pageItem.locator('.tlui-page_menu__item__submenu button')
504+
const submenuButton = pageItem.getByTestId('page-menu.item-submenu')
463505
await submenuButton.click()
464506

465507
// Click delete option
@@ -495,7 +537,7 @@ test.describe('page menu', () => {
495537
const lastPageItem = await pageMenu.getPageItem(2)
496538

497539
// Click the submenu button
498-
const submenuButton = lastPageItem.locator('.tlui-page_menu__item__submenu button')
540+
const submenuButton = lastPageItem.getByTestId('page-menu.item-submenu')
499541
await submenuButton.click()
500542

501543
// Click move up option
@@ -538,7 +580,7 @@ test.describe('page menu', () => {
538580
const firstPageItem = await pageMenu.getPageItem(0)
539581

540582
// Click the submenu button
541-
const submenuButton = firstPageItem.locator('.tlui-page_menu__item__submenu button')
583+
const submenuButton = firstPageItem.getByTestId('page-menu.item-submenu')
542584
await submenuButton.click()
543585

544586
// Click move down option

assets/translations/main.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -331,11 +331,11 @@
331331
"context-menu.reorder": "Reorder",
332332
"page-menu.title": "Pages",
333333
"page-menu.create-new-page": "Create new page",
334-
"page-menu.max-page-count-reached": "Max pages reached",
335334
"page-menu.new-page-initial-name": "Page 1",
336335
"page-menu.edit-start": "Edit",
337336
"page-menu.edit-done": "Done",
338337
"page-menu.go-to-page": "Go to page",
338+
"page-menu.resize": "Resize page list",
339339
"page-menu.submenu.rename": "Rename",
340340
"page-menu.submenu.duplicate-page": "Duplicate",
341341
"page-menu.submenu.title": "Menu",
@@ -493,4 +493,4 @@
493493
"ui.close": "Close",
494494
"ui.checked": "Checked",
495495
"ui.unchecked": "Unchecked"
496-
}
496+
}

0 commit comments

Comments
 (0)