Reputation: 145
Using functional components and Hooks in React, I'm having trouble moving focus to newly added elements. The shortest way to see this is probably the following component,
function Todos (props) {
const addButton = React.useRef(null)
const [todos, setTodos] = React.useState(Immutable.List([]))
const addTodo = e => {
setTodos(todos.push('Todo text...'))
// AFTER THE TODO IS ADDED HERE IS WHERE I'D LIKE TO
// THROW THE FOCUS TO THE <LI> CONTAINING THE NEW TODO
// THIS WAY A KEYBOARD USER CAN CHOOSE WHAT TO DO WITH
// THE NEWLY ADDED TODO
}
const updateTodo = (index, value) => {
setTodos(todos.set(index, value))
}
const removeTodo = index => {
setTodos(todos.delete(index))
addButton.current.focus()
}
return <div>
<button ref={addButton} onClick={addTodo}>Add todo</button>
<ul>
{todos.map((todo, index) => (
<li tabIndex="0" aria-label={`Todo ${index+1} of ${todos.size}`}>
<input type="text" value={todos[index]} onChange={e => updateTodo(index, e.target.value)}/>
<a onClick={e => removeTodo(index)} href="#">Delete todo</a>
</li>
))}
</ul>
</div>
}
ReactDOM.render(React.createElement(Todos, {}), document.getElementById('app'))
FYI, todos.map
realistically would render a Todo
component that has the ability to be selected, move up and down with a keyboard, etc… That is why I'm trying to focus the <li>
and not the input within (which I realize could be done with the autoFocus
attribute.
Ideally, I would be able to call setTodos
and then immediately call .focus()
on the new todo, but that's not possible because the new todo doesn't exist in the DOM yet because the render hasn't happened.
I think I can work around this by tracking focus via state but that would require capturing onFocus
and onBlur
and keeping a state variable up to date. This seems risky because focus can move so wildly with a keyboard, mouse, tap, switch, joystick, etc… The window could lose focus…
Upvotes: 1
Views: 12128
Reputation: 1
Spent a little while trying to fix this for myself while learning React the other day & I thought I'd share my workaround. For context I was trying to set focus to a <div>
that I was creating while rendering a list of entries to a table.
The problem in my case was that useEffect()
wasn't reliably setting focus for two reasons :
I needed to focus on the element in two different cases, one where it was on the page without loading new elements and one where it would be on the page after new elements were rendered. The first problem is pretty straight forward, updating when I was setting focus based on an accurate dependency in useEffect()
fixed the problem.
The element I wanted to focus on wasn't rendered yet if I was loading new entries. This one was trickier but I slept on it and of course fixed it in about 10 minutes the following morning.
While trying to focus on the new <div>
I noticed that no matter where in my code I tried to access the element using document.getElementById();
it was always null. The answer to this problem is to go to where React actually renders the new <div>
. In my case I had already broken this <div>
into a separate React Component called Entry.
By passing a prop to Entry containing a boolean
value based on whether or not the Entry matched the criteria for focus you can know in the Entry Component whether or not to set focus. From here it's as simple as useEffect()
with an empty dependency array to check on Entry render whether you should set focus using document.getElementById().focus();
.
const TablePage = () => {
const [info, setInfo] = useState(null);
const [lookup, setLookup] = useState(null);
/* This will focus on the div if it's already on the page
note the use of optional chaining to avoid errors if the lookup doesn't
have an id field or if the element to focus isn't on the page */
useEffect(() => {
document.getElementById(lookup?.id)?.focus());
}, [lookup]);
return (
<div>
{info.map((info, index) => (
<Entry
focus={info.id == lookup?.id ? true : false}
key={info.id}
info = {info}
/>
))}
</div>
)
}
Then the Entry Component
const Entry = ({ info, focus }) => {
useEffect(() => {
if(focus) document.getElementById(info.id).focus();
}, []);
return (
// your component structure here
)
}
Upvotes: 0
Reputation: 549
Use a useEffect
that subscribes to updates for todos
and will set the focus once that happens.
example:
useEffect(() => {
addButton.current.focus()
}, [todos])
UPDATED ANSWER:
So, you only had a ref on the button. This doesn't give you access to the todo itself to focus it, just the addButton
. I've added a currentTodo
ref and it will be assigned to the last todo by default. This is just for the default rendering of having one todo and focusing the most recently added one. You'll need to figure out a way to focus the input if you want it for just a delete.
ref={index === todos.length -1 ? currentTodo : null}
will assign the ref to the last item in the index, otherwise the ref is null
import React, { useState, useEffect } from 'react';
import ReactDOM from 'react-dom';
function Todos(props) {
const currentTodo = React.useRef(null)
const addButton = React.useRef(null)
const [todos, setTodos] = useState([])
useEffect(() => {
const newTodos = [...todos];
newTodos.push('Todo text...');
setTodos(newTodos);
// event listener for click
document.addEventListener('mousedown', handleClick);
// removal of event listener on unmount
return () => {
document.removeEventListener('mousedown', handleClick);
};
}, []);
const handleClick = event => {
// if there's a currentTodo and a current addButton ref
if(currentTodo.current && addButton.current){
// if the event target was the addButton ref (they clicked addTodo)
if(event.target === addButton.current) {
// select the last todo (presumably the latest)
currentTodo.current.querySelector('input').select();
}
}
}
const addTodo = e => {
const newTodo = [...todos];
newTodo.push('New text...');
setTodos(newTodo);
}
// this is for if you wanted to focus the last on every state change
// useEffect(() => {
// // if the currentTodo ref is set
// if(currentTodo.current) {
// console.log('input', currentTodo.current.querySelector('input'));
// currentTodo.current.querySelector('input').select();
// }
// }, [todos])
const updateTodo = (index, value) => {
setTodos(todos.set(index, value))
}
const removeTodo = index => {
setTodos(todos.delete(index))
currentTodo.current.focus()
}
return <div>
<button onClick={addTodo} ref={addButton}>Add todo</button>
<ul>
{todos.length > 0 && todos.map((todo, index) => (
<li tabIndex="0" aria-label={`Todo ${index + 1} of ${todos.length}`} key={index} ref={index === todos.length -1 ? currentTodo : null}>
<input type="text" value={todo} onChange={e => updateTodo(index, e.target.value)} />
<a onClick={e => removeTodo(index)} href="#">Delete todo</a>
</li>
))}
</ul>
</div>
}
ReactDOM.render(React.createElement(Todos, {}), document.getElementById('root'))
Upvotes: 3
Reputation: 1752
Just simply wrap the focus()
call in a setTimeout
setTimeout(() => {
addButton.current.focus()
})
Upvotes: -1