-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add shift+click selection support to ILE #8441
Add shift+click selection support to ILE #8441
Conversation
Codecov ReportAttention:
Additional details and impacted files@@ Coverage Diff @@
## master #8441 +/- ##
==========================================
- Coverage 16.62% 15.91% -0.71%
==========================================
Files 84 86 +2
Lines 4433 4731 +298
Branches 758 837 +79
==========================================
+ Hits 737 753 +16
- Misses 3212 3443 +231
- Partials 484 535 +51 ☔ View full report in Codecov by Sentry. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sweet thanks @mheiman !! A few small pieces of feedback 👍
openlibrary/plugins/openlibrary/js/ile/utils/SelectionManager/SelectionManager.js
Outdated
Show resolved
Hide resolved
openlibrary/plugins/openlibrary/js/ile/utils/SelectionManager/SelectionManager.js
Outdated
Show resolved
Hide resolved
openlibrary/plugins/openlibrary/js/ile/utils/SelectionManager/SelectionManager.js
Outdated
Show resolved
Hide resolved
openlibrary/plugins/openlibrary/js/ile/utils/SelectionManager/SelectionManager.js
Outdated
Show resolved
Hide resolved
($(clickEvent.target).closest('a').is('a') && | ||
$(clickEvent.target).not('.ile-select-handle').length > 0)) return; | ||
|
||
const el = clickEvent.currentTarget; | ||
const elIndex = $(el).closest('li,tr')?.index(); | ||
|
||
if (clickEvent.shiftKey && this.lastSelectedIndex !== -1 && Math.abs(elIndex - this.lastSelectedIndex) > 1) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm we could have a default lastSelected
here if one is missing ; default to the closest sibling element that's selected. That should handle the case where selection are present on page load, but we can't determine what the 'last' selected element was.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is more complicated than it seems, particularly when you can shift+click either above or below selected elements, and you can shift+click to either select or deselect a range. I think trying to guess the right element to prepopulate here will end up wrong more often than not.
} else { | ||
affectedHandles = allHandles.slice(elIndex, this.lastSelectedIndex); | ||
} | ||
const stateChange = el.classList.contains('ile-selected') ? 'deselect' : 'select'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this behaviour is a little odd, I think I would expect it to select everything inbetween if there are any unselected in the range. So something like const hasUnselectedBetween = $(el).prevUntil(this.lastSelected).filter(':not(.ile-selected)')
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Based on this comment, I don't think you're accounting for the fact that you can use shift+click to modify a range either before or after the last element clicked, and that you can use that shift-click to either select or deselect in bulk. That's the complexity that makes this code complicated. We have to compare the first and second clicks to find the elements between them, either forward or backward, and determine whether this is a selection or deselection action. I think if you play with it a little it will make more sense.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
video1880256056.mp4
A few corrections to the video:
- 00:31 "It'll deselect everything" ----> "It'll deselect these two selected items"
- 01:27 "I think it should only deselect if everything I'm shift-clicking over is already selected" (not deselected :P)
And to clarify there are a few ways we could implement this, but the Google Photos approach felt rather natural to me (except for the part where it randomly stopped working :P). I think the logical bit is: everything is relative to the last clicked item. It'll add to the selection by default. It'll only remove from the selection if there's nothing else it can do. I'm surprised there isn't just like a known solution to how this should be implemented! Or maybe I just haven't found it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see what you're getting at now. My logic was to change the state based on the first click, but your preference (and Google's) is to change the state based on the second click. I can live with that. I've changed the two lines required to switch the behavior.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Niiiice this solution looks great! A few small code changes other lgtm :)
openlibrary/plugins/openlibrary/js/ile/utils/SelectionManager/SelectionManager.js
Outdated
Show resolved
Hide resolved
openlibrary/plugins/openlibrary/js/ile/utils/SelectionManager/SelectionManager.js
Outdated
Show resolved
Hide resolved
openlibrary/plugins/openlibrary/js/ile/utils/SelectionManager/SelectionManager.js
Outdated
Show resolved
Hide resolved
openlibrary/plugins/openlibrary/js/ile/utils/SelectionManager/SelectionManager.js
Show resolved
Hide resolved
openlibrary/plugins/openlibrary/js/ile/utils/SelectionManager/SelectionManager.js
Outdated
Show resolved
Hide resolved
if ((forceSelected === true && isCurSelected) || (forceSelected === false && !isCurSelected)) return; | ||
this.setElementSelectionAttributes(el, !isCurSelected); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That boolean logic is a touch confusing;
if ((forceSelected === true && isCurSelected) || (forceSelected === false && !isCurSelected)) return; | |
this.setElementSelectionAttributes(el, !isCurSelected); | |
this.setElementSelectionAttributes(el, forceSelected == null ? !isCurSelected : forceSelected); |
And set the default to be null
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Your solution is doing something different (that doesn't work :) My confusing logic is there to handle any elements in the range that don't need to be modified because they're already flipped the way they should be. In that case, we just want to bail out of the method because otherwise the element will be incorrectly double-added or removed from the selection set.
The logic is IF (element state is already correct) return ELSE do all the toggle operations.
After staring at it for a bit, I realized I could simplify the test to if (isCurSelected === forceSelected) return;
which may be a little less obscure.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice! Ideally we'd make setElementSelectionAttributes
work when called multiple times, but considering that out of scope of this pr!
openlibrary/plugins/openlibrary/js/ile/utils/SelectionManager/SelectionManager.js
Outdated
Show resolved
Hide resolved
} else { | ||
affectedHandles = allHandles.slice(elIndex, this.lastSelectedIndex); | ||
} | ||
const stateChange = el.classList.contains('ile-selected') ? 'deselect' : 'select'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
video1880256056.mp4
A few corrections to the video:
- 00:31 "It'll deselect everything" ----> "It'll deselect these two selected items"
- 01:27 "I think it should only deselect if everything I'm shift-clicking over is already selected" (not deselected :P)
And to clarify there are a few ways we could implement this, but the Google Photos approach felt rather natural to me (except for the part where it randomly stopped working :P). I think the logical bit is: everything is relative to the last clicked item. It'll add to the selection by default. It'll only remove from the selection if there's nothing else it can do. I'm surprised there isn't just like a known solution to how this should be implemented! Or maybe I just haven't found it.
Co-authored-by: Drini Cami <cdrini@gmail.com>
Co-authored-by: Drini Cami <cdrini@gmail.com>
Co-authored-by: Drini Cami <cdrini@gmail.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok! One last change ; Google photos is doing something slightly different ; the code for it is pretty simple and I think it's a clearer experience! Code otherwise lgtm, will put on testing.openlibrary.org after the change!
const stateChange = this.lastClicked.classList.contains('ile-selected') ? true : false; | ||
for (const element of affectedElements) this.toggleSelected(element, stateChange); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
const stateChange = this.lastClicked.classList.contains('ile-selected') ? true : false; | |
for (const element of affectedElements) this.toggleSelected(element, stateChange); | |
// Select the affected elements if any are unselected | |
const select = affectedElements.some(el => !el.classList.contains('ile-selected')); | |
for (const element of affectedElements) this.toggleSelected(element, select); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This suggestion seems to be based on the assumption that we're only selecting ranges, but my solution implements deselecting a range with shift+click as well, which isn't something that Google Photos does.
I think you're trying to do something that's already implemented, only in toggleSelected rather than here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Google photos implements range deselection with shift-click, but only if everything in the range is selected. Let's jump on a call in a bit to hammer through this 😁 Thanks for your patience!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Talked over the options on a call ; we're still not aligned on what the best ux is 😁 But I'm happy to roll with what we have here and see how it works! Putting on testing, code lgtm
Closes #8212
This PR implements support for using shift+click to select/deselect ranges of items in the ILE.
There are different models for how shift+click selection works in interfaces, particularly with regard to how intermediate toggled items are handled; e.g. If a list is unselected except for item 3, and you click on 1 to select it and then shift+click on 5 to extend the selection, does 3 switch states or remain selected? I judged that the most common use case would be selecting or deselecting all the items in a range, and implemented accordingly.
The shift+click function requires two clicks, one to establish the starting point and the action (select/deselect) and one (with the shift key) to indicate the end of the range. Ranges may be selected in either direction. If the shifted click is a different action than the previous click, no range will be set (e.g. item 5 is already selected; you click to select item 1, then shift+click to deselect item 5. Only item 5 is affected)
Because the ILE remembers selections, a user might load a list that already has item 1 selected, and attempt to shift+click on item 5 to extend the range. Accommodating that use case introduces the possibility of other unwanted behaviors, so I did not address it.
Technical
Lists of selectable items in OL don't follow any consistent HTML pattern, so in order to determine the range of selectable items on various pages, I've had to do some on-the-fly tree traversal. It works in all current contexts, and probably will in any new ones that show up.
Shift+clicking on a web page often triggers extended text selections which are unwelcome in this context. It's possible to disable text selection altogether, but that's obviously not appropriate. After some lengthy searching, I was unable to come up with a solution that worked cross-browser to disable selections only in a shift+click context. As a second-best solution, this code clears any text selections that are accidentally created. You may see a brief flash of the selection in the interface.
Testing
This should be tested in the following contexts:
Author pages ( /OL18319A/Mark_Twain )
Work pages ( /books/OL7652865M/Adventures_of_Tom_Sawyer_(Webster's_French_Thesaurus_Edition )
Book search ( /search?q=a )
Author search ( /search/authors?q=t* )
Stakeholders
@cdrini @mheiman