Detachable Components
Open your Vue components in external window
16/12/2023
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.