buttery-components-logo
buttery.tools

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:

  1. Name
  2. Age
  3. 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.

APIs

useModal