Pointer Holder
Remembers which elements the pointer has crossed since it entered, and forgets them all the moment it leaves. One listener on the parent, no handlers on the children.
API
Props
| prop | type | default | |
|---|---|---|---|
children | (api: PointerHolderApi) => ReactElement | — | receives the held keys and returns the container; the element is cloned, not wrapped |
attribute? | string | "data-margo-pointer-holder" | the attribute a key is read from, and the one that marks an element as holdable |
disabled? | boolean | false | stops holding new keys; releasing keeps working |
Examples
Usage
<PointerHolder attribute="aria-label">
{({ keys }) => (
<div className="flex gap-1">
<Button aria-label="theme" open={keys.includes("theme")}>…</Button>
<Button aria-label="like" open={keys.includes("like")}>…</Button>
<Button aria-label="share" open={keys.includes("share")}>…</Button>
</div>
)}
</PointerHolder>Details
Delegation, not wiring
The children get no handlers. pointerover bubbles, so a single listener on the cloned container sees every element underneath, however deeply it is nested and however many wrappers sit in between. Adding a control to the row costs nothing, not even a subscription.
Note the pair: pointerover, not pointerenter. The first bubbles, the second does not, and the second is the one you reach for by habit.
Where a key comes from
An event carries a node, and a key has to be read off that node. So the holder walks up from the target — which is whatever sits deepest, the icon inside the button rather than the button — until it finds an element carrying attribute, and takes its value.
const holder = target.closest(`[${attribute}]`);
const key = holder?.getAttribute(attribute) ?? null;Which is why the example above points attribute at something the controls already carry, and adds nothing to them. A row of links identifies itself by href, a toolbar of shortcuts by aria-keyshortcuts, an icon row by its labels — the identity is already in the markup, it just has to be named.
<PointerHolder attribute="href">
{({ keys }) => (
<nav>
<Button as={Link} to={row.to} open={keys.includes(row.to)}>
…
</Button>
</nav>
)}
</PointerHolder>Be aware that the attribute is also the filter: with href anything linking inside the container is held, whether or not you test its key. Harmless, but pick an attribute the row does not share with things that are not part of it.
When nothing identifying exists, fall back to the default data-margo-pointer-holder and add it by hand — preferably on a wrapper rather than on a component: on a plain node it is unconditionally part of the DOM, while on a component it only lands there if that component forwards unknown props.
<PointerHolder>
{({ keys }) => (
<ul>
<li data-margo-pointer-holder="play">
<ButtonPlayer open={keys.includes("play")} />
</li>
</ul>
)}
</PointerHolder>Either way an element without that attribute is simply never held — silently, since there is nothing to report. It is the first thing to check when a row stays closed.
It holds, it does not decide
The render prop hands back keys and nothing else. The holder has no opinion on what being held should look like, which is why it is not called ButtonHolder: the same array can open a label, highlight the last one touched, or dim everything that was not.
open={keys.includes(row.to)}
active={keys.at(-1) === row.to}
className={cn(keys.length && !keys.includes(row.to) && "opacity-40")}The order is the order in which the keys were caught, so at(-1) is the most recent one and the array doubles as a small trail.
Holding and releasing
Everything is released together, never one at a time: pointerleave of the container empties the array in one go. That is the behaviour worth naming — the row fills up as you cross it and resets when you step out, rather than following the pointer one element at a time.
Only the pointer is watched, which is what the name says. Focus is deliberately not: a trail is a spatial idea, and a keyboard moves one control at a time with no notion of having crossed the others. Whatever a control should show while focused, it already knows how to show on its own.
Touch
On touch a pointerover arrives but the matching leave often never does, which would leave the row held open for good. So touch pointers are ignored and the holder simply does nothing there.
disabled stops new keys from being held but keeps releasing them, so flipping it while a row is open empties it instead of freezing it.