@@ -17,6 +17,26 @@ import {
1717import { CellComponent } from '../cell/cell.component' ;
1818import { DashboardStore } from '../store/dashboard-store' ;
1919import { 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}
0 commit comments