@praha/toi
v1.1.0
Published
A tiny headless React utility for building imperative dialogs/toasts
Readme
@praha/toi
What "toi" Means
toi is the Japanese word 問い — a question (pronounced "toh-ee", close to the English toy). The kanji 問 is a mouth (口) set inside a gate (門): someone calling in at the gate and waiting for an answer. That's the whole library in one character — you ask a component something and await the answer:
const confirmed = await toi(Confirm);The logo keeps the picture: the gate drawn in ink, and the mouth replaced by a vermillion seal — the answer already stamped and waiting inside. Read the full story in What "toi" Means.
👏 Getting Started
Installation
npm install @praha/toiUsage
Render ToiHost once, anywhere in your component tree, to give toi a place to mount the components it renders.
import { ToiHost } from '@praha/toi';
const App = () => (
<>
<YourApp />
<ToiHost />
</>
);Call toi with a component to mount it into the ToiHost and await the value passed to its resolve prop.
import { toi } from '@praha/toi';
import type { ToiProps } from '@praha/toi';
import type { FC } from 'react';
const Confirm: FC<ToiProps<boolean>> = ({ ref, resolve }) => (
<dialog ref={ref} open>
<button onClick={() => resolve(true)}>OK</button>
<button onClick={() => resolve(false)}>Cancel</button>
</dialog>
);
const confirmed = await toi(Confirm);resolve can also be called with no argument for components that don't need to resolve with a value, such as toasts.
const Toast: FC<ToiProps> = ({ ref, resolve }) => (
<div ref={ref} onAnimationEnd={() => resolve()}>
Saved!
</div>
);
await toi(Toast);Components also receive a reject prop. Calling it rejects the promise instead, so await toi(Confirm) throws. It's meant for when the component can no longer answer — work it performs before resolving fails, or the user navigates away — not for ordinary dismissals like a "Cancel" button, which should resolve with a value.
const Confirm: FC<ToiProps<boolean>> = ({ ref, resolve, reject }) => (
<dialog ref={ref} open>
<button onClick={() => resolve(false)}>Cancel</button>
<button
onClick={async () => {
try {
await deleteItem();
resolve(true);
} catch (error) {
reject(error);
}
}}
>
Delete
</button>
</dialog>
);
try {
const deleted = await toi(Confirm);
} catch (error) {
// deleteItem() failed
}Called without a reason, reject rejects with a DOMException named AbortError, following the AbortSignal convention.
Note that navigating to another page doesn't reject the promise on its own. Components rendered by toi live in the ToiHost, which usually sits outside the routed part of your tree, so they stay mounted across page transitions. To close a component when the user navigates away, listen for the Navigation API's currententrychange event and call reject() from it.
import { useEffect } from 'react';
const Confirm: FC<ToiProps<boolean>> = ({ ref, resolve, reject }) => {
useEffect(() => {
const abandon = () => reject();
navigation.addEventListener('currententrychange', abandon);
return () => navigation.removeEventListener('currententrychange', abandon);
}, [reject]);
return (
<dialog ref={ref} open>
<button onClick={() => resolve(true)}>OK</button>
<button onClick={() => resolve(false)}>Cancel</button>
</dialog>
);
};Once resolve or reject is called, the component stays mounted until any running animations (excluding infinite ones) on the element attached to ref finish, so exit animations can play out before it's removed from the ToiHost and the promise settles.
Use toi.fn to bind a component to toi once and reuse the resulting function.
const confirm = toi.fn(Confirm);
const confirmed = await confirm();Pass a second argument to toi for components that need additional props beyond ref, resolve, and reject.
type ConfirmProps = ToiProps<boolean> & { message: string };
const Confirm: FC<ConfirmProps> = ({ ref, resolve, message }) => (
<dialog ref={ref} open>
<p>{message}</p>
<button onClick={() => resolve(true)}>OK</button>
<button onClick={() => resolve(false)}>Cancel</button>
</dialog>
);
const confirmed = await toi(Confirm, { message: 'Are you sure?' });toi.fn's second argument works the same way, but as default props: they're used whenever the returned function is called without its own props argument, and can be overridden per call by passing props anyway.
const confirm = toi.fn(Confirm, { message: 'Are you sure?' });
const confirmed = await confirm(); // uses the default message: 'Are you sure?'
const confirmedAgain = await confirm({ message: 'Really?' }); // overrides it🤝 Contributing
Contributions, issues and feature requests are welcome.
Feel free to check issues page if you want to contribute.
📝 License
Copyright © PrAha, Inc.
This project is MIT licensed.
