Skip to content

Commit 4480dc1

Browse files
TobyBackstromclaude
andcommitted
feat(ngx-dashboard): add selectionModifier, dragThreshold, and PointerEvent support
Implements the three additions from the atom team's selection API proposal: - selectionModifier: optional 'shift' | 'ctrl' | 'alt' | 'meta' input on DashboardComponent. When set, the selection overlay stays mounted but is pointer-events: none until the modifier is held (or a drag started under it is in progress). Lets widget click / contextmenu handlers coexist with drag-to-select. Default null preserves legacy always-armed behavior. - dragThreshold: number input (default 4 px) suppressing selectionComplete emissions for sub-threshold gestures. Eliminates the click-emits-1x1 footgun. Set to 0 for legacy behavior. - PointerEvent migration: viewer's selection handlers now use pointerdown / pointermove / pointerup with defensive setPointerCapture and elementFromPoint cell tracking via data-row / data-col attributes. Touch and pen drag-to-select now work. Internal: armed() computed signal gates active CSS under .selection-overlay-grid.armed. Modifier listeners register via Renderer2.listen inside an effect that activates only when a modifier is configured, so default-config dashboards pay no global keystroke cost. Tests: +18 new tests (491 total in ngx-dashboard, was 473), covering modifier gating, mid-drag latch, blur reset, threshold boundaries, and PointerEvent / touch / pen / capture paths. Demo and lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 905a50d commit 4480dc1

9 files changed

Lines changed: 578 additions & 143 deletions

File tree

projects/ngx-dashboard/src/lib/dashboard-viewer/__tests__/dashboard-viewer.component.spec.ts

Lines changed: 351 additions & 86 deletions
Large diffs are not rendered by default.

projects/ngx-dashboard/src/lib/dashboard-viewer/dashboard-viewer.component.html

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
<!-- Selection overlay grid - mirror of main grid for cell selection -->
2323
@if (enableSelection()) {
24-
<div class="selection-overlay-grid">
24+
<div class="selection-overlay-grid" [class.armed]="armed()">
2525
@for (row of rowNumbers(); track row) {
2626
@for (col of colNumbers(); track col) {
2727
<div
@@ -30,8 +30,9 @@
3030
[class.selecting]="isSelecting()"
3131
[style.grid-row]="row"
3232
[style.grid-column]="col"
33-
(mousedown)="onGhostCellMouseDown($event, row, col)"
34-
(mouseenter)="onGhostCellMouseEnter(row, col)"
33+
[attr.data-row]="row"
34+
[attr.data-col]="col"
35+
(pointerdown)="onGhostCellPointerDown($event, row, col)"
3536
></div>
3637
}
3738
}

projects/ngx-dashboard/src/lib/dashboard-viewer/dashboard-viewer.component.scss

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,13 @@
5050
pointer-events: none;
5151
}
5252

53-
/* Selection overlay - mirror grid for cell selection */
53+
/* Selection overlay - mirror grid for cell selection
54+
*
55+
* Always mounted when `enableSelection` is true. Whether the overlay
56+
* intercepts pointer events is controlled by the `.armed` class, which is
57+
* always present in legacy mode (selectionModifier === null) and only
58+
* present while the configured modifier is held / a drag is in progress.
59+
*/
5460
.selection-overlay-grid {
5561
position: absolute;
5662
top: 0;
@@ -65,28 +71,36 @@
6571
grid-template-columns: repeat(var(--columns), var(--cell-size));
6672
grid-template-rows: repeat(var(--rows), var(--cell-size));
6773

68-
z-index: 5; // Above cells to block all interactions
69-
pointer-events: auto; // Block all mouse events from reaching cells below
74+
z-index: 5; // Above cells; only intercepts events when .armed
75+
pointer-events: none; // Default: transparent so widget cells receive events
7076
user-select: none; // Prevent text selection during drag
77+
78+
&.armed {
79+
pointer-events: auto; // Active: block events from reaching cells below
80+
}
7181
}
7282

7383
.selection-ghost-cell {
74-
cursor: crosshair;
7584
transition: background-color 0.1s ease, border-radius 0.1s ease;
7685
border-radius: 2px;
7786

78-
// Hover effect when not selecting
79-
&:hover:not(.selecting) {
80-
background-color: var(--mat-sys-primary);
81-
opacity: 0.08;
82-
}
83-
84-
// Active selection state
87+
// Selected cells render their tint regardless of armed state, so the
88+
// post-selection visual persists until enableSelection flips off.
8589
&.selected {
8690
background-color: var(--mat-sys-primary);
8791
opacity: 0.25;
8892
border-radius: 4px;
8993
}
94+
}
95+
96+
.selection-overlay-grid.armed .selection-ghost-cell {
97+
cursor: crosshair;
98+
99+
// Hover effect when not selecting
100+
&:hover:not(.selecting) {
101+
background-color: var(--mat-sys-primary);
102+
opacity: 0.08;
103+
}
90104

91105
// Cursor during active selection
92106
&.selecting {

projects/ngx-dashboard/src/lib/dashboard-viewer/dashboard-viewer.component.ts

Lines changed: 178 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,26 @@ import {
1717
import { CellComponent } from '../cell/cell.component';
1818
import { DashboardStore } from '../store/dashboard-store';
1919
import { GridSelection } from '../models/grid-selection';
20+
import { SelectionModifier } from '../models/selection-modifier';
21+
22+
/**
23+
* Map a SelectionModifier value to the corresponding KeyboardEvent.key value.
24+
* Used to detect modifier hold/release without reading the boolean
25+
* `*Key` flags, which are unreliable when distinguishing the modifier
26+
* keypress itself from other keys pressed while the modifier is held.
27+
*/
28+
function modifierKeyName(modifier: SelectionModifier): string {
29+
switch (modifier) {
30+
case 'shift':
31+
return 'Shift';
32+
case 'ctrl':
33+
return 'Control';
34+
case 'alt':
35+
return 'Alt';
36+
case 'meta':
37+
return 'Meta';
38+
}
39+
}
2040

2141
@Component({
2242
selector: 'ngx-dashboard-viewer',
@@ -47,6 +67,16 @@ export class DashboardViewerComponent {
4767

4868
// Selection feature
4969
enableSelection = input<boolean>(false);
70+
selectionModifier = input<SelectionModifier | null>(null);
71+
/**
72+
* Minimum pointer movement (in CSS pixels) between pointerdown and
73+
* pointerup required to emit `selectionComplete`. Below the threshold,
74+
* the gesture is treated as a click and no event is emitted.
75+
*
76+
* Default 4 — matches OS-native click-vs-drag thresholds. Set to 0 to
77+
* preserve the legacy behavior where every pointerup emits.
78+
*/
79+
dragThreshold = input<number>(4);
5080
selectionComplete = output<GridSelection>();
5181

5282
// store signals - read-only
@@ -57,6 +87,23 @@ export class DashboardViewerComponent {
5787
selectionStart = signal<{ row: number; col: number } | null>(null);
5888
selectionCurrent = signal<{ row: number; col: number } | null>(null);
5989

90+
// Modifier-key gating state for selectionModifier
91+
readonly #modifierHeld = signal(false);
92+
readonly #dragInProgress = signal(false);
93+
94+
/**
95+
* Whether the selection overlay is currently interactive (intercepts
96+
* pointer events). Always false when `enableSelection` is false.
97+
* When `selectionModifier` is null, true whenever selection is enabled
98+
* (legacy behavior). Otherwise, true only while the configured modifier
99+
* is held or a drag started under the modifier is in progress.
100+
*/
101+
protected readonly armed = computed(() => {
102+
if (!this.enableSelection()) return false;
103+
if (this.selectionModifier() === null) return true;
104+
return this.#modifierHeld() || this.#dragInProgress();
105+
});
106+
60107
// Computed selection bounds (normalized)
61108
selectionBounds = computed(() => {
62109
const start = this.selectionStart();
@@ -79,9 +126,12 @@ export class DashboardViewerComponent {
79126
Array.from({ length: this.columns() }, (_, i) => i + 1)
80127
);
81128

82-
// Document-level event listeners (cleanup needed)
83-
#mouseMoveListener?: () => void;
84-
#mouseUpListener?: () => void;
129+
// Document-level pointer listeners (cleanup needed)
130+
#pointerMoveListener?: () => void;
131+
#pointerUpListener?: () => void;
132+
133+
// Pointer position at the start of a drag, for dragThreshold checks
134+
readonly #pointerDownPos = signal<{ x: number; y: number } | null>(null);
85135

86136
constructor() {
87137
// Sync grid configuration with store when inputs change
@@ -101,6 +151,52 @@ export class DashboardViewerComponent {
101151
this.isSelecting.set(false);
102152
}
103153
});
154+
155+
// Modifier-key tracking. Only registers document/window listeners when a
156+
// modifier is configured, so dashboards using the default (null) pay no
157+
// global keystroke cost. Cleans up listeners and resets state on
158+
// modifier change or component teardown.
159+
effect((onCleanup) => {
160+
const modifier = this.selectionModifier();
161+
if (modifier === null) {
162+
this.#modifierHeld.set(false);
163+
return;
164+
}
165+
166+
const keyName = modifierKeyName(modifier);
167+
168+
const offKeyDown = this.#renderer.listen(
169+
'document',
170+
'keydown',
171+
(event: KeyboardEvent) => {
172+
if (event.key === keyName) {
173+
this.#modifierHeld.set(true);
174+
}
175+
}
176+
);
177+
178+
const offKeyUp = this.#renderer.listen(
179+
'document',
180+
'keyup',
181+
(event: KeyboardEvent) => {
182+
if (event.key === keyName) {
183+
this.#modifierHeld.set(false);
184+
}
185+
}
186+
);
187+
188+
// Cover focus-loss cases where keyup may never fire (Alt-Tab, etc.)
189+
const offBlur = this.#renderer.listen('window', 'blur', () => {
190+
this.#modifierHeld.set(false);
191+
});
192+
193+
onCleanup(() => {
194+
offKeyDown();
195+
offKeyUp();
196+
offBlur();
197+
this.#modifierHeld.set(false);
198+
});
199+
});
104200
}
105201

106202
// Selection methods
@@ -121,28 +217,54 @@ export class DashboardViewerComponent {
121217
}
122218

123219
/**
124-
* Handle mouse down on ghost cell to start selection
220+
* Handle pointer down on a ghost cell to start a selection.
221+
*
222+
* Uses PointerEvent so mouse / touch / pen all work uniformly. Calls
223+
* `setPointerCapture` defensively — if the event target doesn't support
224+
* it (e.g. synthetic test events), we fall back to relying on document
225+
* listeners, which receive bubbled pointer events either way.
125226
*/
126-
onGhostCellMouseDown(event: MouseEvent, row: number, col: number) {
127-
if (!this.enableSelection()) return;
128-
if (event.button !== 0) return; // Only left button
227+
onGhostCellPointerDown(event: PointerEvent, row: number, col: number) {
228+
if (!this.armed()) return;
229+
// Mouse: only respond to the primary (left) button. Touch and pen
230+
// events report `button === 0` for the primary contact already.
231+
if (event.pointerType === 'mouse' && event.button !== 0) return;
129232

130233
event.preventDefault();
131234
event.stopPropagation();
132235

133236
this.isSelecting.set(true);
134237
this.selectionStart.set({ row, col });
135238
this.selectionCurrent.set({ row, col });
239+
this.#dragInProgress.set(true);
240+
this.#pointerDownPos.set({ x: event.clientX, y: event.clientY });
136241

137-
// Add document-level listeners for drag
138-
this.#mouseMoveListener = this.#renderer.listen(
242+
const target = event.target;
243+
if (
244+
target instanceof Element &&
245+
typeof target.setPointerCapture === 'function'
246+
) {
247+
try {
248+
target.setPointerCapture(event.pointerId);
249+
} catch {
250+
// Browser may reject capture for invalid pointer ids (e.g. some
251+
// synthetic test events). Document-level listeners cover us.
252+
}
253+
}
254+
255+
// Add document-level listeners for drag tracking. Pointer capture
256+
// routes events to the originator element first, but they still bubble
257+
// up to document, so document listeners reliably see every move/up.
258+
this.#pointerMoveListener = this.#renderer.listen(
139259
'document',
140-
'mousemove',
141-
() => this.onDocumentMouseMove()
260+
'pointermove',
261+
(e: PointerEvent) => this.onDocumentPointerMove(e)
142262
);
143263

144-
this.#mouseUpListener = this.#renderer.listen('document', 'mouseup', () =>
145-
this.onDocumentMouseUp()
264+
this.#pointerUpListener = this.#renderer.listen(
265+
'document',
266+
'pointerup',
267+
(e: PointerEvent) => this.onDocumentPointerUp(e)
146268
);
147269

148270
// Register cleanup
@@ -152,57 +274,71 @@ export class DashboardViewerComponent {
152274
}
153275

154276
/**
155-
* Handle mouse enter on ghost cell during selection
277+
* Track the pointer across cell boundaries during a drag.
278+
*
279+
* Replaces the old per-cell `mouseenter` handler. Necessary because
280+
* pointer capture and (on touch) coalesced events make boundary
281+
* crossings unreliable when relying on per-element enter events.
156282
*/
157-
onGhostCellMouseEnter(row: number, col: number) {
283+
private onDocumentPointerMove(event: PointerEvent) {
158284
if (!this.isSelecting()) return;
159-
this.selectionCurrent.set({ row, col });
160-
}
161285

162-
/**
163-
* Handle document mouse move during selection
164-
*/
165-
private onDocumentMouseMove() {
166-
if (!this.isSelecting()) return;
167-
// The actual selection update is handled by onGhostCellMouseEnter
168-
// This just ensures we capture the event
286+
const el = document.elementFromPoint(event.clientX, event.clientY);
287+
const cell = el?.closest<HTMLElement>('.selection-ghost-cell');
288+
if (!cell) return;
289+
290+
const row = Number(cell.dataset['row']);
291+
const col = Number(cell.dataset['col']);
292+
if (Number.isFinite(row) && Number.isFinite(col)) {
293+
this.selectionCurrent.set({ row, col });
294+
}
169295
}
170296

171297
/**
172-
* Handle document mouse up to complete selection
298+
* Complete a selection on pointerup. Emits `selectionComplete` only when
299+
* total pointer movement meets `dragThreshold` — sub-threshold gestures
300+
* are treated as clicks and discarded.
173301
*/
174-
private onDocumentMouseUp() {
302+
private onDocumentPointerUp(event: PointerEvent) {
175303
if (!this.isSelecting()) return;
176304

177305
this.isSelecting.set(false);
178306

179-
// Emit selection event
180-
const bounds = this.selectionBounds();
181-
if (bounds) {
182-
this.selectionComplete.emit({
183-
topLeft: { row: bounds.startRow, col: bounds.startCol },
184-
bottomRight: { row: bounds.endRow, col: bounds.endCol },
185-
});
307+
const start = this.#pointerDownPos();
308+
const moved =
309+
start === null ||
310+
Math.hypot(event.clientX - start.x, event.clientY - start.y) >=
311+
this.dragThreshold();
312+
313+
if (moved) {
314+
const bounds = this.selectionBounds();
315+
if (bounds) {
316+
this.selectionComplete.emit({
317+
topLeft: { row: bounds.startRow, col: bounds.startCol },
318+
bottomRight: { row: bounds.endRow, col: bounds.endCol },
319+
});
320+
}
186321
}
187322

188-
// Clean up listeners
323+
this.#pointerDownPos.set(null);
324+
this.#dragInProgress.set(false);
189325
this.cleanupListeners();
190326

191-
// Don't clear selection - let the parent control when to clear
192-
// Selection remains visible until enableSelection becomes false
327+
// Don't clear selection - let the parent control when to clear.
328+
// Selection remains visible until enableSelection becomes false.
193329
}
194330

195331
/**
196332
* Clean up document-level event listeners
197333
*/
198334
private cleanupListeners() {
199-
if (this.#mouseMoveListener) {
200-
this.#mouseMoveListener();
201-
this.#mouseMoveListener = undefined;
335+
if (this.#pointerMoveListener) {
336+
this.#pointerMoveListener();
337+
this.#pointerMoveListener = undefined;
202338
}
203-
if (this.#mouseUpListener) {
204-
this.#mouseUpListener();
205-
this.#mouseUpListener = undefined;
339+
if (this.#pointerUpListener) {
340+
this.#pointerUpListener();
341+
this.#pointerUpListener = undefined;
206342
}
207343
}
208344
}

projects/ngx-dashboard/src/lib/dashboard/dashboard.component.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
[columns]="store.columns()"
1515
[gutterSize]="store.gutterSize()"
1616
[enableSelection]="enableSelection()"
17+
[selectionModifier]="selectionModifier()"
18+
[dragThreshold]="dragThreshold()"
1719
(selectionComplete)="selectionComplete.emit($event)"
1820
></ngx-dashboard-viewer>
1921
}

0 commit comments

Comments
 (0)