Buttery ComponentsHeadless, accessible & style method agnostic React components that you can import, re-export and/or copy & paste
Buttery CommandsBuild a TS CLI the same way you would define NextJS or Remix routes
Buttery DocsCo-located, SSR ready, dead simple .md & .mdx docs
Buttery TokensEasily create, use, and scale a pure CSS design token system with 100% type-safety
Buttery LogsIsomorphic logging for full-stack apps
Buttery MetaSSR'd meta tags for your SSR'd React app
Modal
A controlled, semantically correct and accessible modal dialog that interupts the user's flow to focus on itself.
The HTML element that appears is a dialog that is enhanced with the modal attribute.
The semantics of a dialog specifically state that a <dialog /> can either be a modal or non-modal dialog.
In order to reduce confusion on what to use when, the <Modal /> component should be used when you want to explicitly stop
the user from their flow to do something else that is contained in the modal that is being launched.
This library exercises the opionin that all non-modal dialogs should be controlled using the Popover API. All Modals
are blocking elements and should be treated as such. In addition, all modals expecting to receive modalRef will surface
modal dialogs which cause the rest of the page to become inert.
Usage
The useModal hook boasts a renderless stateful mechanism that is controlled internally by the <Modal />
component via the useImperativeHandle hook exported from React. This hook allows us to control the modal without
expicitally instantiating a stateful variable such as isOpen in order to control the modal. This keeps the rendering
logic of the modal interal to the <Modal /> component and doesn't muddy the implementation up with needless state.
Accessibility: It's important to note that all of the native modal dialog acessibility rules exist for the
<Modal />component. You can view all of the controls associated with a native modal dialog by viewing the MDN Documentation
To launch a modal, simply import the useModal hook, pass the <Modal /> the modalRef vairable that can be destructed
from the useModal hook, and then use the openModal function to launch it.
Installation
# yarn
yarn add @buttery/components
# npm
npm install @buttery/components
Basic Example
The below example is what you would most likely see in the wild. It includes a modal header, scrollable body, and a footer that contains a close and action button. It has minimal styles but attempts to show you what you can do with those styles.
Unlike other Headless UI libraries, Buttery Components only exports hooks and basic components you need to use relatively complex
functionality. You might think that there would be a <ModalHeader /> , <ModalBody />, and <ModalFooter /> components but
that offers too much opinion on how those components should be implemented and also makes an uncessary complex abstraction to interface
when consuming the components.
Instead, it's up to the user to style those internal components in the modal however they see fit.
Styling
CSS-in-JS
Style Objects
Examples
Barebones
Animation
Different sizes
Code splitting modal content
Styling the :backdrop
Setting internal state before opening
Nested Forms
It's not uncommon to open a modal and see a wizard, form, or some other method of collecting information. In the case that the dialog is a form, we need to ensure that if there is another way of collecting informatio in the form, we handle it correctly.
Use case: Adding another value to select while filling out a form
In this use case, were opening a modal to collect some information about ourselves. We're collecting 3 things:
- Name
- Age
- Favorite color
Name and age are pretty straight forward where we're just adding a string and a number.
<label>
<div>First Name</div>
<input type="text" name="first_name" required />
</label>
<div>
<label>
<div>Age</div>
<input type="number" name="age" required />
</label>
</div>
However, when we get to favorite_color we encounter a typeahead treatment where a few options are presented to
us in a popover. From there we can either select a few of the options or we have the opportunity to add a new one. It's here
where we encounter the trouble.
<label>
<div>Favorite color</div>
<InputTextDropdown
dxOffset={4}
dxPosition="bottom-left"
onChange={handleSearch}
name="favorite_color"
ref={ref}
>
<ul>
{favorite_color
.filter((g) => g.toLowerCase().includes(searchTerm.toLowerCase()))
.slice(0, 5)
.map((favorite_color) => (
<li key={favorite_color}>
<label key={favorite_color}>
<input
type="radio"
name="color-option"
value={favorite_color}
onChange={handleSelectColor}
/>
{favorite_color}
</label>
</li>
))}
</ul>
<div>
<input type="text" name="select-color" />
<input type="hidden" name="add-color" value="add-color" />
<button type="submit">Add</button>
</div>
</InputTextDropdown>
</label>
The above form snippt is simple, however it's in another form that has a different submit button. A first reaction would be
to wrap the above in another form and then add separate submit handler, but that is invalid HTML and you're not going to get
the results that you want.
Instead we can add a hidden field <input type="hidden" name="add-color" /> with a name and a value.
Since this is a popover and we're using a specical <InputTextDropdown /> that adds / removes the content of the popover
from the DOM when it's open / close respectively, we can check for that hidden input in our main or parent submit handler
and "do something" with the values that we're collecting in the dropdown.
const handleSubmit = useCallback<FormEventHandler<HTMLFormElement>>(
(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const formDataObj = Object.fromEntries(formData.entries()) as Record<
string,
string
>;
// check to see if add-color is a part of the form. If it is, we
if (Object.keys(formDataObj).includes("add-color")) {
setValue(formDataObj["select-color"]);
// Send a separate FETCH request to an API / Server Action, etc...
return;
}
// `add-color` isn't in our form which means our dropdown is closed.
// we can go ahead and just submit the main form sans the dropdown
// form.
alert(JSON.stringify(Object.fromEntries(formData.entries()), null, 2));
},
[setValue]
);
Technical Details
A lot of the beef of the functionality lies within the <Modal /> component. The useModal hook exposes a few controls as
well as the ref to intercept those controls that lie within the modal component.
Imperatively controlling the Modal
The ref of the modal is passed through the useImperativeHandle hook where the ref is "replaced" with a few internal mechanisms
such as open close and toggle. This is how the useModal hook can then use the ref as if it were a factory with some functions.
This cleans up the impelementation of how the <Modal /> component is instantiated thus moving any top-level rendering logic to
control the modal, inside of the modal itself.
/**
* Override the ref and add 2 functions to open and close
* the dialog
*/
useImperativeHandle(params.ref, () => {
return {
handleOpen(_, initState = {} as T) {
setModalState(initState);
openPortal();
},
handleClose: closeModal,
nodeRef: iModalRef,
};
});
The above is kind of yucky and doesn't provide a good API. The below code
is the source of the useModal hook which makes the interface
for managing the modal itself.
Re-using modal dialog functionality
All of the functionality of the modal is containeed within the useModalDialog which the <Modal /> uses
to implement the following:
- Open and close the modal dialog
- Wait for any CSS animations to complete when opening and closing the modal
- Create a dynamic node for which the modal is attached to using react Portals.
This hook is created to enable any other custom implementation of a modal dialog
to be re-used using the useModalDialog hook.
