Skip to content

Commit c437b2e

Browse files
authored
fix(tldraw): tighten page menu list height (tldraw#8924)
In order to make the page menu fit its rows without extra trailing space and surface the max-pages limit in the UI, this PR auto-fits the list to the row stack, lowers the resize minimum to one row, refactors the resize handle into a flush 1px divider with a separate hit-test area, and always renders the create button (disabled with a "Maximum pages reached" tooltip when the page count hits `editor.options.maxPages`). Closes tldraw#8862. ### Change type - [x] `bugfix` ### Test plan 1. Open the page menu with multiple pages and confirm the list fits the row stack with no trailing space. 2. Drag the resize handle upward and confirm it stops at a one-row height. 3. Drag the resize handle downward, then double-click it and confirm the list resets to the auto-fit height. 4. Create pages up to `maxPages` and confirm the create button stays visible, becomes disabled, drops its `+` icon, and shows the "Maximum pages reached" tooltip. - [ ] Unit tests - [x] End to end tests ### Release notes - Fix the page menu list height so it no longer leaves extra trailing space when opened, resized, or reset. - Keep the create-page button visible when the page limit is reached, disabled with a "Maximum pages reached" tooltip. ### API changes - Added `'page-menu.max-pages-reached'` to `TLUiTranslationKey` and `DEFAULT_TRANSLATION`. ### Code changes | Section | LOC change | | --------------- | ---------- | | Core code | +53 / -33 | | Tests | +54 / -0 | | Automated files | +1 / -1 |
1 parent 71ccc2a commit c437b2e

7 files changed

Lines changed: 119 additions & 34 deletions

File tree

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,45 @@ import { setupOrReset, sleep } from '../shared-e2e'
55

66
declare const editor: Editor
77

8+
const PAGE_MENU_ITEM_HEIGHT = 36
9+
// The list reserves a few pixels of breathing room below the last row; the
10+
// auto-fit and minimum heights both include it, so the e2e expectations must too.
11+
const LIST_BOTTOM_PADDING = 4
12+
const MIN_PAGE_MENU_LIST_HEIGHT = PAGE_MENU_ITEM_HEIGHT + LIST_BOTTOM_PADDING
13+
814
const isMobileProject = () => test.info().project.name.includes('Mobile')
915

1016
async function expectPageItemToBeCurrent(pageItem: Locator, isCurrent: boolean) {
1117
await expect(pageItem).toHaveAttribute('data-iscurrent', isCurrent ? 'true' : 'false')
1218
}
1319

20+
async function getPageMenuListHeight(pageList: Locator) {
21+
return await pageList.evaluate((elm) => elm.getBoundingClientRect().height)
22+
}
23+
24+
async function expectPageMenuListHeight(pageList: Locator, expectedHeight: number, tolerance = 1) {
25+
await expect
26+
.poll(async () => getPageMenuListHeight(pageList))
27+
.toBeGreaterThanOrEqual(expectedHeight - tolerance)
28+
29+
await expect
30+
.poll(async () => getPageMenuListHeight(pageList))
31+
.toBeLessThanOrEqual(expectedHeight + tolerance)
32+
}
33+
34+
async function dragPageMenuResizeHandle(page: Page, deltaY: number) {
35+
const resizeHandle = page.locator('.tlui-page-menu__resize-handle')
36+
const box = await resizeHandle.boundingBox()
37+
if (!box) throw new Error('Could not find page menu resize handle')
38+
const x = box.x + box.width / 2
39+
const y = box.y + box.height / 2
40+
41+
await page.mouse.move(x, y)
42+
await page.mouse.down()
43+
await page.mouse.move(x, y + deltaY, { steps: 4 })
44+
await page.mouse.up()
45+
}
46+
1447
async function createPagesForReordering(page: Page) {
1548
await page.evaluate(() => {
1649
editor.createPage({ name: 'Page 2' })
@@ -49,6 +82,37 @@ test.describe('page menu', () => {
4982
await expect(header).toBeHidden()
5083
})
5184

85+
test('The page list height fits its rows when opened, resized, and reset', async ({
86+
page,
87+
pageMenu,
88+
}) => {
89+
test.skip(isMobileProject(), 'Desktop page menu resize behavior')
90+
91+
await page.evaluate(() => {
92+
window.localStorage.removeItem('tldraw_page_menu_list_height')
93+
editor.createPage({ name: 'Page 2' })
94+
editor.createPage({ name: 'Page 3' })
95+
})
96+
97+
await pageMenu.pagemenuButton.click()
98+
await expect(pageMenu.pageItems).toHaveCount(3)
99+
100+
const autoFitHeight = PAGE_MENU_ITEM_HEIGHT * 3 + LIST_BOTTOM_PADDING
101+
await expectPageMenuListHeight(pageMenu.pageList, autoFitHeight)
102+
103+
await dragPageMenuResizeHandle(page, 120)
104+
await expect
105+
.poll(async () => getPageMenuListHeight(pageMenu.pageList))
106+
.toBeGreaterThan(autoFitHeight)
107+
108+
await dragPageMenuResizeHandle(page, -300)
109+
await expectPageMenuListHeight(pageMenu.pageList, MIN_PAGE_MENU_LIST_HEIGHT)
110+
111+
await dragPageMenuResizeHandle(page, 120)
112+
await page.locator('.tlui-page-menu__resize-handle').dblclick()
113+
await expectPageMenuListHeight(pageMenu.pageList, autoFitHeight)
114+
})
115+
52116
test('You can change pages', async ({ page, pageMenu }) => {
53117
const { pagemenuButton, pageItems } = pageMenu
54118

assets/translations/main.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,7 @@
332332
"context-menu.reorder": "Reorder",
333333
"page-menu.title": "Pages",
334334
"page-menu.create-new-page": "Create new page",
335+
"page-menu.max-pages-reached": "Maximum pages reached",
335336
"page-menu.new-page-initial-name": "Page 1",
336337
"page-menu.edit-start": "Edit",
337338
"page-menu.edit-done": "Done",

packages/tldraw/api-report.api.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

packages/tldraw/src/lib/ui.css

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -813,27 +813,41 @@
813813

814814
.tlui-page-menu__list__content {
815815
position: relative;
816+
overflow: hidden;
816817
}
817818

818819
.tlui-page-menu__resize-handle {
819820
flex: 0 0 auto;
820821
position: relative;
821-
height: 7px;
822-
margin-bottom: -3px;
822+
height: 1px;
823823
cursor: ns-resize;
824824
touch-action: none;
825+
background: var(--tl-color-divider);
826+
transition: background-color ease-in-out 150ms 80ms;
825827
z-index: 999;
826828
}
827829

830+
/* Hit test area */
831+
.tlui-page-menu__resize-handle::after {
832+
content: '';
833+
position: absolute;
834+
inset: -4px 0;
835+
background: none;
836+
z-index: 2000;
837+
pointer-events: all;
838+
}
839+
840+
/* Thick blue indicator */
828841
.tlui-page-menu__resize-handle::before {
829842
content: '';
830843
position: absolute;
831-
top: 3px;
844+
top: 0;
832845
left: 0;
833846
right: 0;
834-
height: 1px;
835-
background: var(--tl-color-divider);
836-
transition: background-color 100ms;
847+
height: 1.5px;
848+
background: transparent;
849+
pointer-events: none;
850+
z-index: 2001;
837851
}
838852

839853
.tlui-page-menu__resize-handle[data-resizing='true']::before {

packages/tldraw/src/lib/ui/components/PageMenu/DefaultPageMenu.tsx

Lines changed: 31 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,13 @@ import { PageItemInput } from './PageItemInput'
2626
import { PageItemSubmenu } from './PageItemSubmenu'
2727

2828
const PAGE_MENU_LIST_HEIGHT_KEY = 'tldraw_page_menu_list_height'
29-
const MIN_PAGE_MENU_LIST_HEIGHT = 54
3029
const MAX_PAGE_MENU_RENDER_HEIGHT = 800
31-
// Bottom padding included in the absolutely-positioned content stack — so the
32-
// auto-fit list height should mirror it.
33-
const PAGE_MENU_LIST_CONTENT_BOTTOM_PADDING = 4
30+
const LIST_BOTTOM_PADDING = 4
3431
const MAX_PAGE_MENU_AVAILABLE_HEIGHT_RATIO = 0.62
3532
const PAGE_MENU_CREATE_BUTTON_HEIGHT = 40
36-
const PAGE_MENU_RESIZE_HANDLE_HEIGHT = 7
33+
const PAGE_MENU_RESIZE_HANDLE_HEIGHT = 1
3734
const PAGE_MENU_ITEM_HEIGHT = 36
35+
const MIN_PAGE_MENU_LIST_HEIGHT = PAGE_MENU_ITEM_HEIGHT + LIST_BOTTOM_PADDING
3836
const PAGE_MENU_DRAG_THRESHOLD = 5
3937
const PAGE_MENU_AUTO_SCROLL_ZONE = 16
4038
const PAGE_MENU_AUTO_SCROLL_RAMP_DISTANCE = 48
@@ -53,17 +51,22 @@ function readSavedPageMenuListHeight(): number | null {
5351
}
5452
}
5553

56-
function getPageMenuRenderCap(availableHeight: number, hasFooter: boolean): number {
54+
function getPageMenuRenderCap(availableHeight: number): number {
5755
const maxMenuHeight = Math.min(
5856
MAX_PAGE_MENU_RENDER_HEIGHT,
5957
availableHeight * MAX_PAGE_MENU_AVAILABLE_HEIGHT_RATIO
6058
)
61-
const footerHeight = hasFooter
62-
? PAGE_MENU_CREATE_BUTTON_HEIGHT + PAGE_MENU_RESIZE_HANDLE_HEIGHT
63-
: 0
59+
const footerHeight = PAGE_MENU_CREATE_BUTTON_HEIGHT + PAGE_MENU_RESIZE_HANDLE_HEIGHT
6460
return Math.max(MIN_PAGE_MENU_LIST_HEIGHT, maxMenuHeight - footerHeight)
6561
}
6662

63+
function getPageMenuAutoFitListHeight(pageCount: number): number {
64+
return Math.max(
65+
MIN_PAGE_MENU_LIST_HEIGHT,
66+
pageCount * PAGE_MENU_ITEM_HEIGHT + LIST_BOTTOM_PADDING
67+
)
68+
}
69+
6770
/** @public @react */
6871
export const DefaultPageMenu = memo(function DefaultPageMenu() {
6972
const editor = useEditor()
@@ -149,10 +152,13 @@ export const DefaultPageMenu = memo(function DefaultPageMenu() {
149152
editor.timers.requestAnimationFrame(updateAvailableHeight)
150153
}, [editor, isOpen, updateAvailableHeight])
151154

152-
const renderCap = getPageMenuRenderCap(availableHeight, !isReadonlyMode)
153-
const autoFitListHeight =
154-
pages.length * PAGE_MENU_ITEM_HEIGHT + PAGE_MENU_LIST_CONTENT_BOTTOM_PADDING
155+
const renderCap = getPageMenuRenderCap(availableHeight)
156+
const autoFitListHeight = getPageMenuAutoFitListHeight(pages.length)
155157
const renderedListHeight = Math.min(userListHeight ?? autoFitListHeight, renderCap)
158+
const hasReachedMaxPages = pages.length >= editor.options.maxPages
159+
const createPageButtonLabel = msg(
160+
hasReachedMaxPages ? 'page-menu.max-pages-reached' : 'page-menu.create-new-page'
161+
)
156162

157163
const handleResizePointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
158164
e.preventDefault()
@@ -537,7 +543,7 @@ export const DefaultPageMenu = memo(function DefaultPageMenu() {
537543
<div
538544
className="tlui-page-menu__list__content"
539545
data-dragging={dragState !== null}
540-
style={{ height: PAGE_MENU_ITEM_HEIGHT * pages.length + 4 }}
546+
style={{ height: autoFitListHeight }}
541547
>
542548
{pages.map((page, index) => {
543549
const isCurrentPage = page.id === currentPage.id
@@ -678,20 +684,18 @@ export const DefaultPageMenu = memo(function DefaultPageMenu() {
678684
aria-orientation="horizontal"
679685
aria-label={msg('page-menu.resize')}
680686
/>
681-
{!isReadonlyMode && (
682-
<TldrawUiButton
683-
type="menu"
684-
className="tlui-page-menu__create-button"
685-
data-testid="page-menu.create"
686-
tooltip={msg('page-menu.create-new-page')}
687-
title={msg('page-menu.create-new-page')}
688-
disabled={pages.length >= editor.options.maxPages}
689-
onClick={handleCreatePageClick}
690-
>
691-
<TldrawUiButtonLabel>{msg('page-menu.create-new-page')}</TldrawUiButtonLabel>
692-
<TldrawUiButtonIcon icon="plus" small />
693-
</TldrawUiButton>
694-
)}
687+
<TldrawUiButton
688+
type="menu"
689+
className="tlui-page-menu__create-button"
690+
data-testid="page-menu.create"
691+
tooltip={createPageButtonLabel}
692+
title={createPageButtonLabel}
693+
disabled={isReadonlyMode || hasReachedMaxPages}
694+
onClick={handleCreatePageClick}
695+
>
696+
<TldrawUiButtonLabel>{createPageButtonLabel}</TldrawUiButtonLabel>
697+
{!hasReachedMaxPages && <TldrawUiButtonIcon icon="plus" small />}
698+
</TldrawUiButton>
695699
</div>
696700
</TldrawUiPopoverContent>
697701
</TldrawUiPopover>

packages/tldraw/src/lib/ui/hooks/useTranslation/TLUiTranslationKey.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,7 @@ export type TLUiTranslationKey =
336336
| 'context-menu.reorder'
337337
| 'page-menu.title'
338338
| 'page-menu.create-new-page'
339+
| 'page-menu.max-pages-reached'
339340
| 'page-menu.new-page-initial-name'
340341
| 'page-menu.edit-start'
341342
| 'page-menu.edit-done'

packages/tldraw/src/lib/ui/hooks/useTranslation/defaultTranslation.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ export const DEFAULT_TRANSLATION = {
337337
'context-menu.reorder': 'Reorder',
338338
'page-menu.title': 'Pages',
339339
'page-menu.create-new-page': 'Create new page',
340+
'page-menu.max-pages-reached': 'Maximum pages reached',
340341
'page-menu.new-page-initial-name': 'Page 1',
341342
'page-menu.edit-start': 'Edit',
342343
'page-menu.edit-done': 'Done',

0 commit comments

Comments
 (0)