Open your Vue components in external windowlication

Detachable Components

Open your Vue components in external window

Problem

Sometimes one screen isn't enough. Apps where a lot of data is stored and displayed quickly could become complex and hard to read for a user.

In use cases as such, we would probably proceed with desktop applications instead of web ones. But the second variant comes with pros like scalability, continuous updates without user input, and overall availability of the software. What if we need to detach a portion of the application into a separate window, for example, to display incoming messages, compare tables, or fill out a form? Let's see what we can do.

What can we do

Probably, the first thing that comes to your mind are WebSockets or EventStreams. I agree, for complex communication, it would be advisable to use these technologies. But for a simple v-model (which probably will be more than enough to populate a listing table or form), this code works pretty well.

Example

How it's done

Vue has a powerfull feature of creating a reference to DOM Node and ability to controll it programatically after the component is mounted. But did you know you can call functions in the window events of reference? Let's see this portion of the code:

<!-- WindowPortal -->
<template>
  <!-- Create template that only works as a wrapper of the content -->
  <div class="contents" ref="detachableWindow">
    <slot></slot>
  </div>
</template>

<script setup lang="ts">
// Create a v-model bind
const emit = defineEmits(["update:modelValue"]);
const props = defineProps({
  modelValue: Boolean,
});

// References to DOM nodes
const windowRef = ref<Window | null>(null);
const parentElement = ref<HTMLElement | null>(null);
const detachableWindow = ref<HTMLElement | null>(null);

const openPortal = () => {
  // Assign empty open window to ref
  windowRef.value = window.open("", "", "popup,width=100,height=200,left=100,top=200");
  // Guard if items exist
  if (windowRef.value && detachableWindow.value) {
    // Assign a parent, important for bringing back the child to proper node
    parentElement.value = detachableWindow.value.parentElement;
    // Add title of the window
    windowRef.value.document.title = "Hello from the other side";

    // Append the component to the new body
    windowRef.value.document.body.append(detachableWindow.value);

    // Add close event after the window closes
    windowRef.value.addEventListener("beforeunload", closePortal);
  }
};

const closePortal = () => {
  if (windowRef.value) {
    // Append items inside the slot back to the parent
    parentElement.value?.append(...Array.from(windowRef.value.document.body.childNodes));

    // Close the window
    windowRef.value.close();

    // Emit close
    emit("update:modelValue", false);
  }
};

watch(
  () => props.modelValue,
  (open) => {
    if (open) {
      openPortal();
    }
  },
);

onMounted(() => {
  // Add close event when closing the app
  window.addEventListener("beforeunload", closePortal);
});

onUnmounted(() => {
  // Remove all event listeners after unmount
  window.removeEventListener("beforeunload", closePortal);
});
</script>

That's it! Now the content inserted into the slot of this component will be detachable if the v-model value is set to true:

<WindowPortal v-model="open">
  <div>Make me fly</div>
</WindowPortal>

Although there is one problem. The styles that you've worked really hard on are missing in the detached window. Since it's a new HTML document with just a copied body, there are no styles included. Let's create a function that copies the styles from main HTML to the window.

<script setup lang="ts">
// ...

const copyStyles = (sourceDoc: Document, targetDoc: Document) => {
  Array.from(sourceDoc.styleSheets).forEach((styleSheet: CSSStyleSheet) => {
    if (styleSheet instanceof CSSStyleSheet) {
      const newStyleEl = sourceDoc.createElement("style");

      Array.from(styleSheet.cssRules).forEach((cssRule) => {
        newStyleEl.appendChild(sourceDoc.createTextNode(cssRule.cssText));
      });

      targetDoc.head.appendChild(newStyleEl);
    } else if ("href" in styleSheet && (styleSheet as CSSStyleSheet).href) {
      const newLinkEl = sourceDoc.createElement("link");

      newLinkEl.rel = "stylesheet";
      newLinkEl.href = (styleSheet as CSSStyleSheet).href as string;
      targetDoc.head.appendChild(newLinkEl);
    }
  });
};

const openPortal = () => {
  // ...
  if (windowRef.value && detachableWindow.value) {
    // ...

    // Copy the styles from current document to window reference
    copyStyles(document, windowRef.value.document);

    // ...
  }
};

// ...
</script>

Conclusion

Detachable components are a neat trick to easily display large portions of data in seperate window. Like everything that experimental, it has it's limitations but might be usable in some cases.


Code to copy the style links