Skip to content
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

Merged
merged 8 commits into from
Jul 18, 2021
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/badges/cjs.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions docs/badges/umd.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 41 additions & 1 deletion integration-tests/regression.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const { getByText, fireEvent, waitFor, getByLabelText } = require('@testing-library/dom')
const { getByText, queryAllByText, fireEvent, waitFor, getByLabelText } = require('@testing-library/dom')
const { default: userEvent } = require('@testing-library/user-event')
const { startApp } = require('./test-app')

Expand Down Expand Up @@ -221,4 +221,44 @@ describe('Tram-One - regressions', () => {
// cleanup - remove app
appContainer.remove()
})

it('should not error when reseting focus if the number of elements changed', async () => {
Copy link
Member Author

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.

// for focus to work correctly, the element needs to be attached to the document
const appContainer = document.createElement('div')
appContainer.id = 'app'
window.document.body.appendChild(appContainer)

// start the app using a css selector
startApp('#app')

// previously when interacting with an input, if the number of elements decreased
// an error was thrown because the element to focus on no longer existed

// focus on the child input
userEvent.click(getByLabelText(appContainer, 'Sub Mirror Input'))

// verify that the element has focus (before we start changing text)
await waitFor(() => {
expect(getByLabelText(appContainer, 'Sub Mirror Input')).toHaveFocus()
})

// update the state by typing
userEvent.type(getByLabelText(appContainer, 'Sub Mirror Input'), 'Test')

// verify the element has the new value
expect(getByLabelText(appContainer, 'Sub Mirror Input')).toHaveValue('Test')

// verify the element and it's parent have the new value
// also verify that the elements were added above it too (previously this would have failed)
// the element should still have focus
await waitFor(() => {
expect(getByLabelText(appContainer, 'Sub Mirror Input')).toHaveFocus()
expect(queryAllByText(appContainer, '-')).toHaveLength(4)
expect(getByLabelText(appContainer, 'Mirror Input')).toHaveValue('Test')
expect(getByLabelText(appContainer, 'Sub Mirror Input')).toHaveValue('Test')
})

// cleanup - remove app
appContainer.remove()
})
})
5 changes: 4 additions & 1 deletion integration-tests/test-app/sub-mirror-input.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,17 @@ const html = registerHtml({
* component to test url parameters
*/
module.exports = () => {
const mirrorable = useGlobalStore('mirrorable-input', { value: '' })
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we actually needed this , { value: '' }, it was documented as an error #156 , but this works without it

Copy link
Member Author

Choose a reason for hiding this comment

The 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>
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
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",
Copy link
Member Author

Choose a reason for hiding this comment

The 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",
Expand Down
78 changes: 60 additions & 18 deletions src/observe-tag.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 ? -1 : 1
JRJurman marked this conversation as resolved.
Show resolved Hide resolved
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this sorting function prioritizes indices that are closest to a target.
E.g. if the target is 5 out of [1, 2, 3, 4, 5, 6, 7, 8, 9],
then the new sorting would be [5, 4, 6, 3, 7, 2, 8, 1, 9].

I don't know if there's a more elegant way to write this, but this works 👍


// get an array including the element and all it's children
const parentAndChildrenElements = node => {
const children = node.querySelectorAll('*')
return [node, ...children]
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this recursive/ should it be?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

querySelectorAll (which we use in other places as well) will actually get all the children under an element.
image


/**
* 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
Expand All @@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Member Author

@JRJurman JRJurman Jul 18, 2021

Choose a reason for hiding this comment

The 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 document.activeElement). I'm not sure that a more specific query selector would help... 🤔 thinking about it now, we could query selector on only things that have a positive tabIndex?

This might be just another one of those things where I want to do the performance test, and then fix as necessary.

Copy link
Member Author

@JRJurman JRJurman Jul 18, 2021

Choose a reason for hiding this comment

The 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
Copy link
Member Author

Choose a reason for hiding this comment

The 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 tagName (this way, if an input is surrounded by other different elements, we can still find it again and reapply focus)

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
Copy link
Member Author

Choose a reason for hiding this comment

The 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 scrollLeft and scrollTop, even if they don't do anything.

}

const emptyDiv = document.createElement('div')
Expand All @@ -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
Copy link
Member Author

Choose a reason for hiding this comment

The 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why you can check to see if removedElementWithFocusData.selectionStart is truthy? Do null and undefined come back as truthy in this case, or is there a falsy value that you want to return as true?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

selectionStart can be 0 which is a falsy value, so yeah, we have to explicitly check if it's null or undefined.

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
}
}

Expand Down