use Graft Alert

A question that suspends until it is answered: open() returns a Promise.

API

Returns

proptypedefault
isActive?booleanwhether the question is on screen
storage?T | nullthe payload open was called with
open?(payload?: T) => Promise<boolean>asks, and resolves with the answer
close?(result?: boolean) => voidanswers with the result given
onConfirm?() => voidclose(true)
onDismiss?() => voidclose(false)
id?stringstable id, for aria-labelledby

Usage

Ask, and wait

The deletion is written in one place, in the order it happens: ask, and if the answer is yes, delete. The row that started it is the row that finishes it — nothing is handed to the dialog and handed back, and the list has no idea a question was asked.

tsx
// -constants/graft.ts
export const GraftConfirm = createGraft({ name: "GraftConfirm", graft: useGraftAlert<string> });

// -components/file_list.tsx
export const FileList = () => {
  const { list, remove } = GraftFiles.use();
  const { open } = GraftConfirm.use();

  const onDelete = async (file: string) => {
    if (!(await open(file))) return;

    remove(file);
  };

  return (
    <List
      array={list}
      itemExtractor={({ row }) => <Item key={row} onClick={() => void onDelete(row)}>{row}</Item>}
    />
  );
};

// -components/confirm_layer.tsx
export const ConfirmLayer = () => {
  const { id, isActive, onConfirm, onDismiss, storage } = GraftConfirm.use();

  return (
    <Layer open={isActive} onClose={onDismiss} aria-labelledby={id}>
      <Header.Title id={id} title={`Delete ${storage ?? "this file"}?`} />
      <Button onClick={onDismiss}>cancel</Button>
      <Button onClick={onConfirm}>delete</Button>
    </Layer>
  );
};
previewdelete one and cancel, then delete one and confirm

The payload is what the question is about, so the dialog can name the file it is asking to delete without the caller passing it twice. Cancelling resolves false and the line after await simply never runs.

Details

The answer comes back to the caller

This is the same shape as a dialog with one difference, and the difference is the point: open() returns a Promise that resolves when the question is answered. The code that asked reads the answer where it asked, instead of splitting into a callback that has to be handed the rest of the operation.

Which is what makes a confirmation something you can insert into an existing flow without rewriting it. An action that used to close immediately gains a question in front of it and keeps its shape.

tsx
// -constants/graft_dialog.ts
export const GraftDialogInvoice = createGraft({ name: "GraftDialogInvoice", graft: useGraft });

function useGraft() {
  const confirm = GraftConfirm.use();
  const dialog = useGraftDialog<Invoice>();

  const [isDirty, setIsDirty] = useState(false);

  const close = useCallback(async () => {
    if (isDirty && !(await confirm.open())) return;

    dialog.close();
  }, [confirm, dialog, isDirty]);

  return { ...dialog, close, isDirty, setIsDirty };
}

Every question is answered

A pending Promise that never settles is a leak you find months later, so there are none here. Asking again while a question is open resolves the previous one with false, and unmounting the provider does the same — an unanswered question always becomes a no.

close(result) answers with whatever you pass, and onConfirm and onDismiss are the two shorthands you end up wiring to buttons. Anything that dismisses without answering — Esc, the backdrop — should go to onDismiss, so the caller hears a no rather than nothing.