I recently reworked the files manager within Makeswift adding drag selection similiar to the pointer multi-selection I am used to in Finder.
//todo
But I discovered that storing these interactions as vectors is key to creating that sweet sweet lasangna.
Now that we have a grid of items, let's render a "selection rectangle" on drag.
This rectangle is the indicator of what a user is selecting.
Let's create some state to hold our rectangle.
We'll use the DOMRect class since it's the geometry type of the web, and we'll call this state selectionRect.
Next we need to add an onPointerDown to the div surrounding our items. We'll call this div the "container div".
This event handler will initialize a rectangle describing the area of our drag.
Since the selectionRect will be positioned absolutely to the container div, we want to store it relative to the container's position.
We do this by subtracting the container's x/y coordinates from our cursor's x/y coordinates.
To prevent drag events from anything other than the left mouse button we can early return when e.button !== 0.
Then in onPointerMove, we update our selectionRect based on the next position of the pointer.
This new x/y position is again relative to the container, so we offset selectionRect's position based on the container.
In onPointerUp we reset our state.
And finally we render the selectionRect.
Here is the diff:
And a demo of the interaction so far. Give it a try!
From a cursory look, our example seems to be working, but it only works in one direction.
When dragging left/up, the starting point of our drag moves left/up.
This is because the x/y of our DOMRect is the start of our drag, but height and width can't be negative.
So when we drag left/up we have to reset the x/y of our DOMRect.
The root issue is width and height are expressions of magnitude not direction.
We need a datatype that expresses both the magnitude and direction of the users action.
This concept of magnitude + direction is called a vector [1]. A vector quantity is one that can't be expressed as a single number.
Instead it is comprised of direction and magnitude. DOMRects are so close to being vector quantities but the names height and width limit your thinking to one quadrant.
The DOMRect constructor doesn't throw when you pass negative width and height values,
but names are important.
Having more descriptive names will help us make reasoning about this interaction easy.
To start let's create our own DOMVector class with x, y, magnitudeX, magnitudeY
We then need to update our selectionRect state to instead store a dragVector.
At render time we will derive the DOMRect of our selection from the dragVector.
I generally try to avoid components that "derive" values on render.
I think this is why I have tried to store drag operations as DOMRects for so long.
A DOMRect is what should be rendered, but a DOMRect is a lossy form of storing the state, so logically I don't think this derivation can be avoided.
Finally, we can replace our DOMRect constructor calls with DOMVector constructor calls,
and update our onPointerMove to calculate magnitudeX and magnitudeY instead of width and height.
Now that we are drawing the selectionRect, we need to actually select the items in our selection.
We need to iterate each item's DOMRect and see if it intersects with our selectionRect.
The most common way of getting a DOMRect in React is by storing a ref to that item.
In our case, this would mean storing an array of refs to each of our items.
But storing a data structure of refs has always seemed unwieldy to me.
The datastructure is usually already expressed in the structure of the DOM.
When you represent that structure in two places, your component iinevitably becomes harder to iterate on.
To avoid this problem, libraries like RadixUI add data attributes to each items and use querySelector to find the related DOM node at event time.
This is what we'll be doing.
Let's start by creating some state for holding our selected items
and get a ref to the continer div.
Then we can add a data-item attribute to each item.
We'll use it to store the "identifier" of our item. In the real world, these boxes would be entities in your system so you would use ids.
Now let's create a helper function called updateSelectedItems.
This function finds all our items,
get's their DOMRect relative to the container,
and checks if they intersect with the selectionRect
Once updateSelectedItems has looped through each of the items it will push the local state to the selectedItems state.
To make it obvious that we selected something, let's create an indicator for the number of selected items.
And update the items to have different styles when they are selected.
One way to prevent text selection would be to use select-none on the container.
This shotgun approach to the problem works, but we lose the ability to select text at all.
You can imagine a scenario where each item has a URL you might want to copy or some identifier that is useful to reference this entity in another system.
To preserve text selection while preventing text selection during our drag we should do two things.
Only start dragging if the drag has reached a certain threshold distance
If text has been selected during that time prevent our drag from occuring
At this point, there isn't a good way to deselect items.
Let's fix that.
Drag to select selection is usually preserved when a user clicks outside of the container but cleared when the user clicks within the container.
We can model that by updating our onPointerUp to clear selection when there isn't a current drag.
Drag to select selection is also usually cleared when the esc key is hit.
To model this we can focus the container element when our drag is started and add an keyboard event listener for "Escape" that clears our selection.
We use tabIndex -1 to prevent this input from showing up in the tab order
We preventDefault() to prevent this escape key press from closing any dialogs or resulting in any unintention behavior.
And we update the focus styles to match our design system and to keep the focus and selection styles unique
Focusing the container, but hiding it from the tab order is a hack.
Ideally we would use active descendant to enable keyboard driven multi select.
Users could keyboard navigate to our container and with proper aria attributes it would indicate how to interact and which elements are selected.
Since this keyboard interaction is only for clearing drag to select operations, I am using tabIndex === -1 to prevent keyboard navigation.
I may research doing a keyboard interaction like this. Subscribe if you are interested!
Up until this point we have been working on a small group of items. Often times though, grids like this are scrollable and contain many items.
This is where modelling our drag as a vector is going to really pay off.
I wrote and rewrote this example many times but once scroll entered the picture this would iinevitably become spagetti.
Having a type with descript nomenclature really pays off in the readability of this code.
Ok enough hyping it up. Let's walk through it.
To start, let's represent our scroll as a vector.
Just like our drag it has a magnitude and direction.
Then we can create a add method on DragVector
and use it to combine our drag and scroll to create our selection Rect.
Something I learned when making this example is that you can only get clientX and clientY from pointer events.
Before I stored drag and scroll as distinct values, this meant I had to cache the last pointer event, so I could recreate the selection Rect within onScroll.
That whole class of problems is avoided by storing them as separate values.
Next need to create an onScroll event that capture user scroll, updates the scrollVector, and calls our updateSelectedItems helper.
The DOMRect our items need to take into account the scroll of the container.
And finally, we can add a max height and col configuration for both vertical and horizontal overflow
and render a bunch more items to cause overflow
selectable area
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
This is great. But now our container has this odd overflow.
To fix that we can clamp the selection Rect to the size of the container.
A clamp function in animation is a function that keeps you within certain bounds.
Most of the time it's for a number but the concept also works for our DOMVector within a DOMRect.
Let's create a clamp method on our DOMVector
Then we can use it with the scrollWidth and scrollHeight of our container
This is great, but there is a nice feature of text selection where it auto scrolls for you when you drag to the edge of the container.
This autoscrolls close to the edge is also a key part of making drag interactions.
// don't forget to call out that you created the terminal point method
We need a way to trigger a scroll when there is a current drag, but no user scroll or pointer events are taking place.
To accomplish this we can setup a requestAnimationFrame when the user is dragging
Conceptually, we are saying "When the user is dragging, run this code on every frame and scroll our container if certain criteria are met.
The math here is a bit hard to read, so let's walk through the case of scrolling down.
First we create a variable for when we should scroll down
When your pointer is within the container and not within 20 pixels of the edge, this value is greater than 20 and we shouldn't scroll.
//todo diagram
Otherwise, It's less than 20 and we should scroll
//todo diagram
If we should scroll down, then we set our top variable to a positive value
If you are 19 pixels from the edge of the container has a height of 100 then this value is 20 - 100 + 81 = 1
As you approach the edge this value increases.
If you are 1 pixel from the edge of the container then this value is 20 -100 + 99 = 19
This allows the user to control the speed of the scroll as they approach the edge, while capping the value at 20 to prevent them from overscrolling.
Now that we have a top value it's scroll time.
selectable area
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
My big takeaways from writing this post was that Vectors are a great way to hold drag state.