From 58e6c3a853660361c64634ca1bf1e7d09efe3b3c Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 1 Oct 2026 22:31:27 +0200 Subject: [PATCH] fix(dashboard): tell pointer focus apart without touching the DOM FocusMonitor toggles cdk-*-focused classes on the monitored track, so a mouse press on a source card mutated the DOM before the click. The J2 "open a source" performance journey rejects iterations with DOM activity between its settle snapshot and the click. Read the input modality from the CDK InputModalityDetector in a focusin handler instead: it only listens, so the rail stays untouched until the click. Co-Authored-By: Claude Opus 5.5 --- .../lib/rails/dashboard-rail.component.html | 1 + .../src/lib/rails/dashboard-rail.component.ts | 34 +++++++++---------- 2 files changed, 17 insertions(+), 18 deletions(-) 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 a9312a33d..74fe5d5d2 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 @@ -50,6 +50,7 @@ class="rail__track" [attr.data-layout]="layout()" (scroll)="onScroll()" + (focusin)="onTrackFocusIn($event)" role="list" > @for (card of items(); track card.id) { 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 5335c0fe5..6d9bde303 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,7 @@ import { viewChild, viewChildren, } from '@angular/core'; -import { FocusMonitor } from '@angular/cdk/a11y'; +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 +135,7 @@ export interface DashboardRailActionSelection { }) export class DashboardRailComponent implements AfterViewInit, OnDestroy { private readonly settingsStore = inject(SettingsStore); - private readonly focusMonitor = inject(FocusMonitor); + private readonly inputModality = inject(InputModalityDetector); readonly label = input.required(); readonly items = input.required(); @@ -210,19 +210,10 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy { this.updateScrollState() ); this.resizeObserver.observe(this.track().nativeElement); - // Every focus inside the track, with where it came from. 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. - this.focusMonitor.monitor(this.track(), true).subscribe((origin) => { - if (origin === 'keyboard' || origin === 'program') { - this.revealFocusedCard(); - } - }); } ngOnDestroy(): void { this.resizeObserver?.disconnect(); - this.focusMonitor.stopMonitoring(this.track()); this.intersectionObserver?.disconnect(); this.cancelPendingReset(); } @@ -296,7 +287,8 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy { /** * Brings a card that receives keyboard or programmatic focus (Tab or - * `focus()`) fully into view. + * `focus()`) fully into view; focus that follows 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 * when the rail overflows by less than a card. A plain "nearest" scroll @@ -305,13 +297,19 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy { * snap position that reveals the whole card. The visible area is the * viewport, not the track, whose padding bleeds under the fades. */ - private revealFocusedCard(): void { - const track = this.track().nativeElement; + 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; const card = - track.ownerDocument.activeElement?.closest( - '.rail__card' - ); - if (!card || !track.contains(card)) return; + event.target instanceof Element + ? event.target.closest('.rail__card') + : null; + if (!card) return; + const track = this.track().nativeElement; const visible = this.viewport().nativeElement.getBoundingClientRect(); const rect = card.getBoundingClientRect(); const hiddenLeft = rect.left < visible.left - 1;