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

proptypedefault
children(api: PointerHolderApi) => ReactElementreceives 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?booleanfalsestops holding new keys; releasing keeps working

Examples

Usage

tsx
<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>
previewhover across the row, then leave it

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.

tsx
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.

tsx
<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.

tsx
<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.

tsx
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.