From a8b2dd946acb8fad5cbedcf7554d138966ee9458 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Wed, 17 Jun 2026 11:02:45 +0100 Subject: [PATCH 1/6] feat(dotcom): sidebar polish and dialog dismiss fixes (#9243) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In order to keep workspace dialogs open while their nested controls are in use and to tidy up the new sidebar, this PR generalizes dialog dismissal and reorganizes the sidebar actions. Portalled controls can now opt out of dismissing a dialog by adding a `data-tlui-dialog-prevent-dismiss` attribute, so interacting with a `TlaMenuSelect` popup inside the workspace settings dialog no longer registers as a background click and closes it — replacing the previous all-or-nothing `preventBackgroundClose` workaround. `TlaSidebarWorkspaceActions` is extracted into its own component and rendered directly from `TlaSidebar`, the user settings menu opens on right-click, and clearing the sidebar search blurs the input instead of refocusing it. ### Change type - [x] `improvement` ### Test plan 1. Open a workspace's settings dialog from the sidebar. 2. Open one of the role/permission selects and pick an option — the dialog should stay open. 3. Click the dialog overlay — the dialog should close. 4. Right-click the user settings row in the sidebar — the user settings menu should open. 5. Type in the sidebar search, then click the clear (X) button — the input should clear and lose focus. - [ ] Unit tests - [x] End to end tests ### Code changes | Section | LOC change | | -------------- | ----------- | | Core code | +18 / -1 | | Tests | +27 / -12 | | Apps | +197 / -177 | ### Release notes - Keep tldraw.com workspace dialogs open when interacting with nested select menus. - Open the sidebar user settings menu on right-click. --- .../client/e2e/fixtures/scenario-test.ts | 26 +- .../client/e2e/tests/ui.scenario.spec.ts | 11 +- .../tla/components/TlaSidebar/TlaSidebar.tsx | 5 + .../components/TlaSidebarSearch.tsx | 8 +- .../components/TlaSidebarUserSettingsMenu.tsx | 14 +- .../components/TlaSidebarWorkspaceActions.tsx | 85 ++++++ .../TlaSidebarWorkspaceSwitcher.tsx | 259 ++++++------------ .../src/tla/components/tla-menu/tla-menu.tsx | 2 +- 8 files changed, 222 insertions(+), 188 deletions(-) create mode 100644 apps/dotcom/client/src/tla/components/TlaSidebar/components/TlaSidebarWorkspaceActions.tsx diff --git a/apps/dotcom/client/e2e/fixtures/scenario-test.ts b/apps/dotcom/client/e2e/fixtures/scenario-test.ts index 7b1ff6b598ac..328a00dd9544 100644 --- a/apps/dotcom/client/e2e/fixtures/scenario-test.ts +++ b/apps/dotcom/client/e2e/fixtures/scenario-test.ts @@ -1,7 +1,7 @@ import fs from 'fs' import { setupClerkTestingToken } from '@clerk/testing/playwright' import { expect, test as base } from '@playwright/test' -import type { Browser, BrowserContext, Download, Page, TestInfo } from '@playwright/test' +import type { Browser, BrowserContext, Download, Locator, Page, TestInfo } from '@playwright/test' import { NUMBER_OF_USERS } from '../consts' import { Database, getTestUserEmail } from './Database' import { DeleteFileDialog } from './DeleteFileDialog' @@ -22,6 +22,17 @@ type WorkspaceMemberRole = 'owner' | 'member' const SCENARIO_USER_POOL_START = 4 const ROOT_URL = 'http://localhost:3000' +const MENU_INTERACTION_TIMEOUT = 5_000 + +export async function selectTlaMenuOption(page: Page, select: Locator, optionLabel: string) { + await select.click({ timeout: MENU_INTERACTION_TIMEOUT }) + const openListbox = page.locator('[role="listbox"][data-state="open"]') + await expect(openListbox).toBeVisible({ timeout: MENU_INTERACTION_TIMEOUT }) + await openListbox + .getByRole('option', { name: optionLabel, exact: true }) + .click({ timeout: MENU_INTERACTION_TIMEOUT }) + await expect(openListbox).not.toBeVisible({ timeout: MENU_INTERACTION_TIMEOUT }) +} interface SignedInActorAccount { email: string @@ -359,8 +370,7 @@ class DotcomScenario { const select = actor.page.getByTestId('shared-link-type-select') const expectedLabel = linkType === 'edit' ? 'Editor' : 'Viewer' if ((await select.innerText()) !== expectedLabel) { - await select.click() - await actor.page.getByRole('option', { name: expectedLabel }).click() + await selectTlaMenuOption(actor.page, select, expectedLabel) } await expect(select).toHaveText(expectedLabel) await actor.waitForMutationResolution() @@ -537,8 +547,11 @@ class DotcomScenario { memberUserId: string }) { await opts.owner.sidebar.openWorkspaceSettings(opts.workspaceName) - await opts.owner.page.locator(`[id="workspace-member-role-${opts.memberUserId}"]`).click() - await opts.owner.page.getByRole('option', { name: 'Remove' }).click() + await selectTlaMenuOption( + opts.owner.page, + opts.owner.page.locator(`[id="workspace-member-role-${opts.memberUserId}"]`), + 'Remove' + ) await opts.owner.page.getByRole('button', { name: 'Remove member' }).click() await opts.owner.waitForMutationResolution() await opts.owner.page.keyboard.press('Escape') @@ -555,8 +568,7 @@ class DotcomScenario { `[id="workspace-member-role-${opts.memberUserId}"]` ) const roleLabel = opts.role === 'owner' ? 'Owner' : 'Member' - await memberRoleSelect.click() - await opts.owner.page.getByRole('option', { name: roleLabel }).click() + await selectTlaMenuOption(opts.owner.page, memberRoleSelect, roleLabel) await expect(memberRoleSelect).toHaveText(roleLabel) await opts.owner.waitForMutationResolution() await opts.owner.page.keyboard.press('Escape') diff --git a/apps/dotcom/client/e2e/tests/ui.scenario.spec.ts b/apps/dotcom/client/e2e/tests/ui.scenario.spec.ts index 92b26c3cfb47..a3f306262a6f 100644 --- a/apps/dotcom/client/e2e/tests/ui.scenario.spec.ts +++ b/apps/dotcom/client/e2e/tests/ui.scenario.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '../fixtures/scenario-test' +import { expect, selectTlaMenuOption, test } from '../fixtures/scenario-test' const ROOT_URL = 'http://localhost:3000' @@ -240,9 +240,12 @@ test.describe('UI scenarios', () => { await expect(ownerDialog.getByRole('button', { name: 'Copy invite link' })).toBeVisible() await expect(ownerDialog.getByText('Members', { exact: true })).toBeVisible() await expect(ownerDialog.getByText(/\(you\)/)).toBeVisible() - await expect(ownerDialog.locator(`[id="workspace-member-role-${memberUserId}"]`)).toHaveText( - 'Member' - ) + const memberRoleSelect = ownerDialog.locator(`[id="workspace-member-role-${memberUserId}"]`) + await expect(memberRoleSelect).toHaveText('Member') + + // Interacting with the portalled role select should not count as a background click. + await selectTlaMenuOption(owner.page, memberRoleSelect, 'Member') + await expect(ownerDialog).toBeVisible() // Copying and regenerating the invite link update clipboard-visible state. const inviteInput = ownerDialog.locator('input[readonly]').first() diff --git a/apps/dotcom/client/src/tla/components/TlaSidebar/TlaSidebar.tsx b/apps/dotcom/client/src/tla/components/TlaSidebar/TlaSidebar.tsx index ebf95dfb2c2d..3ec3514c18ba 100644 --- a/apps/dotcom/client/src/tla/components/TlaSidebar/TlaSidebar.tsx +++ b/apps/dotcom/client/src/tla/components/TlaSidebar/TlaSidebar.tsx @@ -1,4 +1,5 @@ import { memo, useCallback, useEffect } from 'react' +import { useActiveWorkspaceId } from '../../hooks/useActiveWorkspaceId' import { useHasFlag } from '../../hooks/useHasFlag' import { useTldrFileDrop } from '../../hooks/useTldrFileDrop' import { useTldrawAppUiEvents } from '../../utils/app-ui-events' @@ -15,6 +16,7 @@ import { TlaSidebarFeedbackButton } from './components/TlaSidebarFeedbackButton' import { TlaSidebarRecentFiles } from './components/TlaSidebarRecentFiles' import { TlaSidebarRecentFilesNew } from './components/TlaSidebarRecentFilesNew' import { TlaUserSettingsMenu } from './components/TlaSidebarUserSettingsMenu' +import { TlaSidebarWorkspaceActions } from './components/TlaSidebarWorkspaceActions' import { TlaSidebarWorkspaceLink } from './components/TlaSidebarWorkspaceLink' import { TlaSidebarWorkspaceSwitcher } from './components/TlaSidebarWorkspaceSwitcher' import styles from './sidebar.module.css' @@ -47,6 +49,7 @@ export const TlaSidebar = memo(function TlaSidebar() { const { onDrop, onDragOver, onDragEnter, onDragLeave } = useTldrFileDrop() const workspacesEnabled = useHasFlag('groups_frontend') + const activeWorkspaceId = useActiveWorkspaceId() return (