The colour picker looked fine. You could click a swatch, drag around the saturation square, and then click into the hex field, type six characters, and watch absolutely nothing happen.
What it was not
The input was not disabled and had no readOnly. Its onChange worked — picking a
swatch updated the displayed value through the same state, and dispatching a change event
at the node ran the handler. Nothing was covering it: document.elementFromPoint at the
centre of the field returned the input itself.
What was missing was focus. After clicking the field, document.activeElement was still
document.body. No caret, no selection, and every keystroke went to the document instead
of the input — which is why the field behaved like a picture of a field.
Portals escape the DOM, not the React tree
The popover is rendered with createPortal into document.body, for the usual reason:
the toolbar has overflow: hidden and a stacking context, and a popover that lives inside
it gets clipped.
What moves is the DOM node. The event path does not. React's synthetic events propagate
along the React tree — the component hierarchy you wrote — not the DOM tree the browser
ended up with. A mousedown inside a portaled popover is still delivered to the React
ancestors of the component that called createPortal, however far away those ancestors
sit in the document.
In our case the ancestor was the formatting toolbar:
function Toolbar() {
return (
<div onMouseDown={(e) => e.preventDefault()}>
<ColourButton /> {/* renders its popover into document.body */}
</div>
)
}
In the DOM the popover was a sibling of the whole application. As far as React was
concerned it was a child of that div.
The handler was doing its job
That preventDefault is not a mistake; most rich text editors have some version of it.
Pressing a toolbar button with the pointer moves focus off the editable canvas, which
collapses the selection, which means that by the time the button's onClick fires there is
nothing selected to make bold. Cancelling the default action of mousedown keeps focus and
selection where they were, and the button applies to the text the user actually chose.
The part that is easy to forget is what else that default action does. Focusing the
element you clicked is the default behaviour of mousedown. Cancel it and nothing gains
focus — not the toolbar, which is the point, and not the hex input, which is not. The
field was being denied focus by a handler written for a different element, reached through
a tree that does not exist in the DOM.
stopPropagation, not preventDefault
The reflex when a parent handler misbehaves is to call preventDefault in the child. That
makes it worse. The input needs the default behaviour: focus on click, caret placement
at the character you clicked, drag to select. Nothing about the browser's handling of that
event is wrong.
What has to stop is the event reaching a handler that will cancel it:
createPortal(
<div className="picker-popover" onMouseDown={(e) => e.stopPropagation()}>
<input value={hex} onChange={(e) => setHex(e.target.value)} />
</div>,
document.body,
)
Two calls that get used interchangeably and should not:
| What is wrong | What to call |
|---|---|
| The browser's default action is wrong for you | preventDefault() |
| An ancestor's handler is wrong for you | stopPropagation() |
Since React 17 the real DOM listener lives on the root container and React replays propagation itself, so what you are stopping here is React's walk up the component tree — which is the walk that was causing the problem.
Ask the event, not the code
What settled this was not reading components. We had a shortlist of suspects and a plausible story for each, which is the state in which you can spend an hour being confidently wrong. Ask the event instead:
const el = document.querySelector('.picker-popover input')
const ev = new MouseEvent('mousedown', { bubbles: true, cancelable: true })
el.dispatchEvent(ev)
console.log(ev.defaultPrevented) // true — something upstream cancelled it
A bubbling, cancellable mousedown reaches React's root listener, React runs its handlers,
and any preventDefault one of them calls shows up on the native event you still hold.
true converts "which of these five things might be responsible" into "something cancels
mousedown above this node", which is a grep.
There is a second hint nearby. Chrome's getEventListeners($0) on the input shows nothing
interesting, because React's listeners are not on the input — and that emptiness is the
tell that you should stop reading the DOM and start reading the component tree.
What to check when a field will not take focus
Four questions, in order, each with a one-line answer in the console. Is the node hit
testable — elementFromPoint at its centre. Does it receive the event — a listener that
logs. Is the default prevented — defaultPrevented on a dispatched event. Does it end up
focused — document.activeElement. Whichever one first answers no is where the bug is,
and answering them in order stops you rewriting a component that was never at fault.
The general form: a portal changes where a node lives, not where its events go. If you
moved something to document.body to escape a z-index or an overflow, that is all you
escaped — every handler above you in the React tree is still above you.