-
Notifications
You must be signed in to change notification settings - Fork 8
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
Fix input focus on re-ordered elements #164
Changes from all commits
6d5544e
3718f63
b0fc88a
0729401
074339c
b6d9479
0275c31
6d19044
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,14 +8,17 @@ const html = registerHtml({ | |
* component to test url parameters | ||
*/ | ||
module.exports = () => { | ||
const mirrorable = useGlobalStore('mirrorable-input', { value: '' }) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think we actually needed this There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In reality, I just need to create a minimal example of this... |
||
const mirrorable = useGlobalStore('mirrorable-input') | ||
|
||
const onEvent = event => { | ||
mirrorable.value = event.target.value | ||
} | ||
|
||
const letterSpans = [...new Array(mirrorable.value.length)].map(() => html`<span class="letter-span">-</span>`) | ||
|
||
return html` | ||
<div> | ||
${letterSpans} | ||
<label for="sub-mirrorable-input">Sub Mirror Input</label> | ||
<input id="sub-mirrorable-input" class="main input-component" type="text" value=${mirrorable.value} onkeyup=${onEvent} /> | ||
</div> | ||
|
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,6 @@ | ||
{ | ||
"name": "tram-one", | ||
"version": "10.1.5", | ||
"version": "10.1.6", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. no API change, fixes an issue with a feature implementation, so patch is good 👍 |
||
"description": "🚋 Modern View Framework for Vanilla Javascript", | ||
"main": "dist/tram-one.cjs.js", | ||
"commonjs": "dist/tram-one.cjs.js", | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,24 @@ | ||
const { observe } = require('@nx-js/observer-util') | ||
const { TRAM_TAG_REACTION, TRAM_TAG_NEW_EFFECTS, TRAM_TAG_CLEANUP_EFFECTS } = require('./node-names') | ||
|
||
// functions to go to nodes or indicies (made for .map) | ||
const toIndicies = (node, index) => index | ||
const toNodes = allNodes => index => allNodes[index] | ||
|
||
// sorting function that prioritizes indicies that are closest to a target | ||
// e.g. target = 3, [1, 2, 3, 4, 5] => [3, 2, 4, 1, 5] | ||
const byDistanceFromIndex = targetIndex => (indexA, indexB) => { | ||
const diffFromTargetA = Math.abs(indexA - targetIndex) | ||
const diffFromTargetB = Math.abs(indexB - targetIndex) | ||
return diffFromTargetA - diffFromTargetB | ||
} | ||
|
||
// get an array including the element and all it's children | ||
const parentAndChildrenElements = node => { | ||
const children = node.querySelectorAll('*') | ||
return [node, ...children] | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this recursive/ should it be? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
||
/** | ||
* This is a helper function for the dom creation. | ||
* This function observes any state values used when making the tag, and allow it to update | ||
|
@@ -15,24 +33,32 @@ module.exports = tagFunction => { | |
let oldTag = tagResult | ||
const removedElementWithFocusData = { | ||
index: null, | ||
tagName: null, | ||
selectionStart: null, | ||
selectionEnd: null, | ||
selectionDirection: null | ||
selectionDirection: null, | ||
scrollLeft: null, | ||
scrollTop: null | ||
} | ||
|
||
// remove oldTag first so that we unobserve before we re-observe | ||
if (oldTag) { | ||
// if there was focus, we need to figure out what element has it | ||
const children = oldTag.querySelectorAll('*') | ||
const parentAndChildrenNodes = [oldTag, ...children] | ||
removedElementWithFocusData.index = parentAndChildrenNodes.findIndex(element => element === document.activeElement) | ||
const allElements = parentAndChildrenElements(oldTag) | ||
removedElementWithFocusData.index = allElements.findIndex(element => element === document.activeElement) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we do a query selector here for active element, instead of checking against a large list created by a wildcard selector? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The goal here is to find the index of the element, not the element itself (if that's all we needed, we could just use This might be just another one of those things where I want to do the performance test, and then fix as necessary. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I do agree that if there are performance issues, it's that we have to query and iterate over all the children of an element before and after. I'm not a fan either... |
||
|
||
// if an element had focus, copy over all the selection data (so we can copy it back later) | ||
if (removedElementWithFocusData.index >= 0) { | ||
const removedElementWithFocus = parentAndChildrenNodes[removedElementWithFocusData.index] | ||
// get the actual element | ||
const removedElementWithFocus = allElements[removedElementWithFocusData.index] | ||
|
||
// copy over the data | ||
removedElementWithFocusData.tagName = removedElementWithFocus.tagName | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. since we now search for the element we previously had focus on, we want to store the |
||
removedElementWithFocusData.selectionStart = removedElementWithFocus.selectionStart | ||
removedElementWithFocusData.selectionEnd = removedElementWithFocus.selectionEnd | ||
removedElementWithFocusData.selectionDirection = removedElementWithFocus.selectionDirection | ||
removedElementWithFocusData.scrollLeft = removedElementWithFocus.scrollLeft | ||
removedElementWithFocusData.scrollTop = removedElementWithFocus.scrollTop | ||
Comment on lines
+60
to
+61
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this preserves the scroll position in an input or textarea that you are typing in. Previously you this would get reset on re-render, so this experience is more in-line with what people would expect. I don't know that there's a great way to test this, so I'm fine not writing an integration test - this is one thing where I think it really requires manual verification... anything else would be just checking properties, which seems disingenuous since all elements support |
||
} | ||
|
||
const emptyDiv = document.createElement('div') | ||
|
@@ -56,19 +82,35 @@ module.exports = tagFunction => { | |
|
||
// if an element had focus, reapply it | ||
if (removedElementWithFocusData.index >= 0) { | ||
const children = tagResult.querySelectorAll('*') | ||
|
||
const elementToGiveFocus = [tagResult, ...children][removedElementWithFocusData.index] | ||
elementToGiveFocus.focus() | ||
|
||
// also try to set the selection, if there is a selection for this element | ||
const hasSelectionStart = removedElementWithFocusData.selectionStart !== null && removedElementWithFocusData.selectionStart !== undefined | ||
if (hasSelectionStart) { | ||
elementToGiveFocus.setSelectionRange( | ||
removedElementWithFocusData.selectionStart, | ||
removedElementWithFocusData.selectionEnd, | ||
removedElementWithFocusData.selectionDirection | ||
) | ||
const allElements = parentAndChildrenElements(tagResult) | ||
|
||
// we'll look through the elements (in order of nodes closest to original index) and find a tag that matches. | ||
// this means if it didn't move, we'll get it right away, | ||
// if it did, we'll look at the elements closest to the original position | ||
const nodeMatchesTagName = node => node.tagName === removedElementWithFocusData.tagName | ||
const elementToGiveFocus = allElements | ||
.map(toIndicies) | ||
.sort(byDistanceFromIndex(removedElementWithFocusData.index)) | ||
.map(toNodes(allElements)) | ||
.find(nodeMatchesTagName) | ||
|
||
// if the element / child exists, focus it | ||
if (elementToGiveFocus !== undefined) { | ||
Comment on lines
+91
to
+98
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the actual fix. We look through all elements now, and verify that the element even exists. Previously, even if the element was removed, we'd still try to focus on something, which caused errors to be thrown. |
||
elementToGiveFocus.focus() | ||
|
||
// also try to set the selection, if there is a selection for this element | ||
const hasSelectionStart = removedElementWithFocusData.selectionStart !== null && removedElementWithFocusData.selectionStart !== undefined | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a reason why you can check to see if There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
if (hasSelectionStart) { | ||
elementToGiveFocus.setSelectionRange( | ||
removedElementWithFocusData.selectionStart, | ||
removedElementWithFocusData.selectionEnd, | ||
removedElementWithFocusData.selectionDirection | ||
) | ||
} | ||
|
||
// also set the scrollLeft and scrollTop (since this is reset to 0 by default) | ||
elementToGiveFocus.scrollLeft = removedElementWithFocusData.scrollLeft | ||
elementToGiveFocus.scrollTop = removedElementWithFocusData.scrollTop | ||
} | ||
} | ||
|
||
|
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.
the test app needed to be updated, and actually caused a previous test to fail without the fix, but it still feels worthwhile to give this it's own test, so that if they ever get refactored, we don't forget about it.