use Graft Popover
Whether something is open, the payload it was opened with, and a ref for what opened it.
API
Options
| prop | type | default | |
|---|---|---|---|
isActiveOnLoad? | boolean | false | open on the first render |
Returns
| prop | type | default | |
|---|---|---|---|
isActive? | boolean | — | whether it is open |
storage? | T | null | — | the payload open was called with |
anchorRef? | RefObject<E | null> | — | a ref for the trigger, typed by the caller |
open? | (payload?: T | null) => void | — | stores the payload, then opens |
close? | () => void | — | closes, leaving the payload in place |
id? | string | — | stable 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.
// -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>
);
};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.
const { anchorRef } = useGraftPopover<Language, HTMLButtonElement>(); // web
const { anchorRef } = useGraftPopover<Language, View>(); // react nativeWhether 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.
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.