MuckRock / documentcloud-frontend

DocumentCloud's front end source code - Please report bugs, issues and feature requests to info@documentcloud.org
https://www.documentcloud.org
GNU Affero General Public License v3.0
15 stars 5 forks source link

Render Modals in Portals #578

Closed allanlasser closed 1 week ago

allanlasser commented 1 week ago

The implementation of modals in #568 has a few patterns we're trying to avoid in this version. First, it breaks the principle of component composition by, second, dispatching a state update to a global modal store. This imperative approach to building UI breaks the assumptions about Svelte components, making things like passing slot components impossible. As @eyeseast wrote yesterday, "for modals, we're sort of stepping out of the composition tree, or jumping back up [...] it creates a cross-dependency between components that are otherwise unrelated, but I can't think of a way around that. This, at least, keeps the relationship explicit."

Stepping out of the component tree reminded me of React's Portals API. While it doesn't look like Svelte has the same feature built-in, it is possible with some community examples and libraries.

Let's consider adding a Portal component (either library or original) that renders a modal component to the root of the layout. This way, adding a modal becomes a matter of rendering a component tree:

<!-- lib/components/common/Portal.svelte -->
<!-- Source: https://github.com/sveltejs/svelte/issues/3088#issuecomment-1065827485 -->

<script lang="ts">
  import { onMount, onDestroy } from 'svelte';

  export let target: HTMLElement | null | undefined = globalThis.document?.body;

  let ref: HTMLElement;

  onMount(() => {
    if (target) {
      target.appendChild(ref);
    }
  })

  // this block is almost needless/useless (if not totally) as, on destroy, the ref will no longer exist/be in the DOM anyways
  onDestroy(() => {
    setTimeout(() => {
      if (ref?.parentNode) {
        ref.parentNode?.removeChild(ref);
      }
    })
  })
</script>

<div bind:this={ref}>
  <slot />
</div>
<!-- exampleWithModal.svelte -->

<script>
  import { writable } from "svelte/store";
  import Portal from "lib/components/common/Portal";
  import Modal from "lib/components/common/Modal";

  const formOpen = writable(false)
</script>

<Button on:click={() => $formOpen = true}>Edit</Button>
{#if $formOpen}
<Portal>
  <Modal on:close={() => $formOpen = false}>
    <h1 slot="title">Form title</h1>
    <Form on:cancel={() => $formOpen = false}>
      <p>Some extra details</p>
    </Form>
  </Modal>
</Portal>
{/if}

(EDIT: Rereading this example, this may be achievable without writable—a simple let formOpen may get the job done!)

This approach has a few improvements on the global-state managed modal: