use Graft Popover

Whether something is open, the payload it was opened with, and a ref for what opened it.

API

Options

proptypedefault
isActiveOnLoad?booleanfalseopen on the first render

Returns

proptypedefault
isActive?booleanwhether it is open
storage?T | nullthe payload open was called with
anchorRef?RefObject<E | null>a ref for the trigger, typed by the caller
open?(payload?: T | null) => voidstores the payload, then opens
close?() => voidcloses, leaving the payload in place
id?stringstable id, for aria-controls

Usage

The trigger and the surface, apart

A popover is two components in two files: the thing you press, and the thing that opens. Both need the same answer to the same question, and neither should own it — so the graft holds it, and each of them takes what it needs out of the context without knowing the other exists.

The demo below renders with margo-ui, which is one way of many: the hook has no opinion about what a popover looks like or how it finds its place on screen.

tsx
// -constants/graft_language.ts
export const GraftLanguage = createGraft({
  name: "GraftLanguage",
  graft: useGraftPopover<Language, HTMLButtonElement>,
});

// -components/language_trigger.tsx
export const LanguageTrigger = () => {
  const { anchorRef, close, id, isActive, open } = GraftLanguage.use();

  return (
    <Button ref={anchorRef} open={isActive} aria-controls={id} onClick={() => (isActive ? close() : open())}>
      <Button.Label label="language" />
    </Button>
  );
};

// -components/language_surface.tsx
export const LanguageSurface = ({ languages }: { languages: readonly Language[] }) => {
  const { close, id, isActive, storage } = GraftLanguage.use();

  return (
    <Popover id={id} open={isActive} onClose={close}>
      <Popover.Body>
        <List
          array={languages}
          itemExtractor={({ row }) => (
            <Item key={row.code} active={storage?.code === row.code} onClick={() => pick(row)}>
              <Item.Label label={row.label} />
            </Item>
          )}
        />
      </Popover.Body>
    </Popover>
  );
};
nothing picked yet

Details

A ref is state like any other

anchorRef is created here and handed over; the hook never looks inside it. That is what keeps the package usable outside the browser: nothing names an element type, so the caller decides what will fill it — a button on the web, a view somewhere else — and the hook is the same in both.

tsx
const { anchorRef } = useGraftPopover<Language, HTMLButtonElement>();  // web
const { anchorRef } = useGraftPopover<Language, View>();               // react native

Whether anything reads it is up to whoever renders. A surface that measures the trigger needs the element; one that leans on the layout around it, as the margo-ui popover does, never asks. The ref is offered, not required — the graft carries it so that the renderer has the choice.

Open is the only writer

open(payload) writes the payload before flipping the flag, and close() leaves it where it is: what you opened with survives the exit, so a closing animation still has something to show and the chip above can read the choice once the surface is gone.

In the demo, picking a language is exactly that pair — store, then close. The chip below keeps reading it with the surface gone, and reopening shows the same row still marked as active.

tsx
const pick = (language: Language) => {
  open(language);
  close();
};

It only holds the answer

The hook knows whether you want it open and what it is about. It does not know what a popover is, where it sits, or that screens have layers at all — that belongs to whatever renders it, and the component that does is free to keep its children a little longer than the flag says while it animates away.