diff --git a/apps/electron-backend-e2e/src/dashboard-rail-focus.e2e.ts b/apps/electron-backend-e2e/src/dashboard-rail-focus.e2e.ts index 93e2a3cfd..be26cb6a0 100644 --- a/apps/electron-backend-e2e/src/dashboard-rail-focus.e2e.ts +++ b/apps/electron-backend-e2e/src/dashboard-rail-focus.e2e.ts @@ -120,7 +120,9 @@ test.describe('Dashboard rail focus', () => { expect(await lastCardLinkFocused(app.mainWindow)).toBe(true); await expect.poll(() => lastCardFullyVisible(rail)).toBe(true); - // `focus()` from script, starting at the rail's start again. + // `focus()` from script after a mouse click elsewhere, starting + // at the rail's start again. + await rail.locator('.rail__label').click(); await rail.evaluate((section) => { (document.activeElement as HTMLElement | null)?.blur(); section diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html index 74fe5d5d2..5182ba368 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html @@ -51,6 +51,7 @@ [attr.data-layout]="layout()" (scroll)="onScroll()" (focusin)="onTrackFocusIn($event)" + (pointerdown)="onTrackPointerDown($event)" role="list" > @for (card of items(); track card.id) { diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts index 4015c5695..af1de8e36 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts @@ -592,23 +592,36 @@ describe('DashboardRailComponent', () => { }); }); - it('reveals the card when the focus comes from the keyboard', async () => { - const { element, scrollTo, focusLink } = await renderRail({ - count: 6, - width: 172, - stride: 186, - }); - - element.dispatchEvent( - new KeyboardEvent('keydown', { bubbles: true, key: 'Tab' }) - ); - focusLink(5); - - expect(scrollTo).toHaveBeenCalledWith({ - left: 102, - behavior: 'auto', - }); - }); + /** + * Presses a card link: a pointerdown (jsdom has no PointerEvent), + * followed for a mouse by its mousedown. A tap's compatibility + * mousedown only comes once the finger lifts. + */ + const pressCard = ( + element: HTMLElement, + index: number, + pointerType: 'mouse' | 'touch' + ) => { + const link = element.querySelectorAll('.rail__card-link')[index]; + const press = new MouseEvent('pointerdown', { bubbles: true }); + Object.defineProperty(press, 'pointerType', { value: pointerType }); + link.dispatchEvent(press); + if (pointerType === 'mouse') { + link.dispatchEvent( + new MouseEvent('mousedown', { + bubbles: true, + buttons: 1, + detail: 1, + }) + ); + } + }; + // Waits out a press's focus window. The rail's first-render reset to + // the start lands meanwhile, so its scrollTo call is forgotten. + const wait = async (ms: number, scrollTo: jest.Mock) => { + await new Promise((resolve) => setTimeout(resolve, ms)); + scrollTo.mockClear(); + }; it('keeps the rail still when a mouse press focuses a partly hidden card', async () => { // Scrolling on mousedown would move the card from under the @@ -619,18 +632,43 @@ describe('DashboardRailComponent', () => { stride: 186, }); - element.querySelectorAll('.rail__card-link')[5].dispatchEvent( - new MouseEvent('mousedown', { - bubbles: true, - buttons: 1, - detail: 1, - }) - ); + pressCard(element, 5, 'mouse'); focusLink(5); expect(scrollTo).not.toHaveBeenCalled(); }); + it('keeps the rail still when a tap focuses the card after the finger lifts', async () => { + const { element, scrollTo, focusLink } = await renderRail({ + count: 6, + width: 172, + stride: 186, + }); + + pressCard(element, 5, 'touch'); + await wait(150, scrollTo); + focusLink(5); + + expect(scrollTo).not.toHaveBeenCalled(); + }); + + it('still reveals a card focused from script after an earlier mouse press', async () => { + const { element, scrollTo, focusLink } = await renderRail({ + count: 6, + width: 172, + stride: 186, + }); + + pressCard(element, 0, 'mouse'); + await wait(150, scrollTo); + focusLink(5); + + expect(scrollTo).toHaveBeenCalledWith({ + left: 102, + behavior: 'auto', + }); + }); + it('leaves the scroll position alone for a fully visible card', async () => { const { scrollTo, focusLink } = await renderRail({ count: 6, diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts index 6d9bde303..112d84728 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts @@ -14,7 +14,6 @@ import { viewChild, viewChildren, } from '@angular/core'; -import { InputModalityDetector } from '@angular/cdk/a11y'; import { MatButtonModule } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; @@ -135,7 +134,6 @@ export interface DashboardRailActionSelection { }) export class DashboardRailComponent implements AfterViewInit, OnDestroy { private readonly settingsStore = inject(SettingsStore); - private readonly inputModality = inject(InputModalityDetector); readonly label = input.required(); readonly items = input.required(); @@ -175,6 +173,8 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy { private readonly visibleCardIds = new Set(); private lastVisibleSignature: string | null = null; private resetFrameId: number | null = null; + /** Last mouse, pen or touch press inside the track. */ + private pointerPress: { timeStamp: number; touch: boolean } | null = null; private settleFrameId: number | null = null; /** @@ -287,7 +287,7 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy { /** * Brings a card that receives keyboard or programmatic focus (Tab or - * `focus()`) fully into view; focus that follows a mouse or touch press + * `focus()`) fully into view; focus caused by a mouse or touch press * leaves the rail where it is. * Chromium skips its own focus scroll when 32px or more of the element * already shows, which left a card partly hidden under the edge fade @@ -298,12 +298,7 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy { * viewport, not the track, whose padding bleeds under the fades. */ onTrackFocusIn(event: FocusEvent): void { - // A mouse or touch press focuses the card it lands on too, but - // scrolling then would slide the card from under the pointer and - // lose the click. The detector only listens, so nothing in the DOM - // changes before the click itself. - const modality = this.inputModality.mostRecentModality; - if (modality === 'mouse' || modality === 'touch') return; + if (this.isFocusFromPointerPress(event)) return; const card = event.target instanceof Element ? event.target.closest('.rail__card') @@ -341,6 +336,29 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy { }); } + onTrackPointerDown(event: PointerEvent): void { + this.pointerPress = { + timeStamp: event.timeStamp, + touch: event.pointerType === 'touch', + }; + } + + /** + * A press focuses the card it lands on too, but scrolling then would + * slide the card from under the pointer and lose the click. A mouse or + * pen focuses on the mousedown right after the pointerdown; a tap only + * with the compatibility mouse events once the finger lifts, so it gets + * the CDK FocusMonitor's 650ms touch buffer. Focus later than that, such + * as `focus()` after a click elsewhere, is not the press's. Only + * timestamps are compared: nothing in the DOM changes before the click. + */ + private isFocusFromPointerPress(event: FocusEvent): boolean { + const press = this.pointerPress; + if (!press) return false; + const elapsed = event.timeStamp - press.timeStamp; + return elapsed >= 0 && elapsed <= (press.touch ? 650 : 100); + } + scrollBy(direction: 1 | -1): void { const el = this.track().nativeElement; el.scrollBy({