Stack Drawers Component
15 May 2023

In an enterprise-level admin UI, there is a high-chance of domain objects with deep hierarchical or navigational structure. For example, in a dashboard page, there is a link to see all products, then for each product, the user might want to see the details of the product, then in the product detail page, the user might want to configure or setup some relationship with another domain object. As you probably realize, the user-journey can go very deep and the user might get lost as to where they came from. A typical solution might be using breadcrumb links at the top of the page so the user can know where they are, however the breadcrumb links allow the user to jump 2 levels up and skip intermediate parents which might not be what we allow the user to do. Another solution I see often is to use modal popups overlapping modal popups which can get the UI visually cluttered easily.

Stack Drawers

One of the solution I really liked when handling such cases of multi-level modals is to use the drawer pattern but allowing it to be stackable over the top. I first saw this UI pattern in Google Analytics or Google Tag admin console, I can’t remember which one though, and I immediately liked it. I couldn’t find time until now to build one in VueJS and I really like the result of it. Here is the demo of it in its full glory! Lets go through some of the key points that was done to achieve the StackDrawer component.

StackDrawers Plugin

Since we have to keep track of all the drawers opened at a given time, we will store the state in a global context. To store state at the app level, we can create a plugin in which all it does is to register a provider using Vue’s provide-inject feature. So here is the object definition to install a plugin in which it provides a StackDrawerGlobalContext interface.

export default {
  install(app: App) {
    app.provide(STACK_DRAWERS_KEY, createStackDrawerGlobalContext());
  },
};

To use it for example when we initialize the App component:

...
import stackDrawersPlugin from "./components/stackDrawersPlugin";

const app = createApp(App);
app.use(router);
app.use(stackDrawersPlugin);
app.mount("#app");

StackDrawersGlobalContext interface

Since we are using TypeScript, lets define the StackDrawersGlobalContext interface and the provide-inject key. For the key, we can use Vue’s InjectionKey generic type where we can define the return type when we use the inject() method. We also want any component in the app to be able to import StackDrawer and have it register and deregister to the global context’s state internally, so we that is what the register and unregister method does.

export const STACK_DRAWERS_KEY = Symbol(
  "StackDrawersGlobalContext"
) as InjectionKey<StackDrawersGlobalContext>;

export interface StackDrawersGlobalContext {
  count: ComputedRef<number>;
  register(drawer: StackDrawerInstanceContext): number;
  unregister(drawer: StackDrawerInstanceContext): void;
}

export interface StackDrawerInstanceContext {
  close(): void;
}

The register() method returns a numbered index that the drawer is identified to. The parameter to pass into the drawer is of type StackDrawerInstanceContext, it only has a close() method. This method is there to be able to be called by the global context. The “topmost” drawer instance’s close() method is to be called when we hit the <Escape> key. For the count: ComputedRef<number> computed property, it is to reactively provide the number of drawers registered at a given time and is watched by each drawer instance to know when to set to full width or partial width.

StackDrawerGlobalContext implementation

Next lets see the implementation of StackDrawerGlobalContext.

export function createStackDrawersGlobalContext(): StackDrawersGlobalContext {
  const stackDrawers: Ref<StackDrawerInstanceContext[]> = ref([]);
  const count = computed(() => stackDrawers.value.length);
  let escKeyHandlerAdded = false;
  function register(drawer: StackDrawerInstanceContext) {
    stackDrawers.value.push(drawer);
    const index = stackDrawers.value.indexOf(drawer);
    return index;
  }
  function unregister(drawer: StackDrawerInstanceContext) {
    const index = stackDrawers.value.indexOf(drawer);
    if (index !== -1) {
      stackDrawers.value.splice(index, 1);
    }
  }
  function keyDownHandler(event: KeyboardEvent) {
    if (event.key === "Escape") {
      const lastDrawer = stackDrawers.value[stackDrawers.value.length - 1];
      if (lastDrawer) {
        lastDrawer.close();
      }
    }
  }
  watch(count, (count) => {
    if (count > 0) {
      if (!escKeyHandlerAdded) {
        document.addEventListener("keydown", keyDownHandler);
        escKeyHandlerAdded = true;
      }
    } else {
      document.removeEventListener("keydown", keyDownHandler);
      escKeyHandlerAdded = false;
    }
  });
  return {
    count,
    register,
    unregister,
  };
}

Pretty straightforward code here, the register() method will add the drawer instance to the array and the unregister() method will remove it from the array. For the watcher, what we do here is to only register the Escape key when a drawer is registered and removed it when all drawers are unregistered.

StackDrawer component

The StackDrawer is basically a wrapped SlidePanel component with the added behaviours of when to show full width or partial width based on how many StackDrawers are registered. Here we see the injected StackDrawerGlobalContext and use the count computed property, alongside the registered index to determine the SlidePanel’s width.

<script setup lang="ts">
import { computed, inject, ref } from "vue";

import SlidePanel from "./SlidePanel.vue";
import { STACK_DRAWERS_KEY } from "./stackDrawersPlugin";
interface Props {
  teleportTo?: string;
  drawerCss?: string;
  topMostDrawerWidthCss?: string;
}
const props = withDefaults(defineProps<Props>(), {
  teleportTo: "body",
  drawerCss: "",
  topMostDrawerWidthCss:
    "w-full sm:w-[calc(100%-8rem)] md:w-[calc(100%-16rem)]",
});
interface Emits {
  (e: "closing"): void;
  (e: "closed"): void;
}
defineEmits<Emits>();
const index = ref<number | undefined>();
const slidePanel = ref<InstanceType<typeof SlidePanel>>();
const context = inject(STACK_DRAWERS_KEY);
const stackDrawersCount = computed(() => context?.count.value ?? 0);
const drawerWidth = computed<string>(() => {
  let result = props.topMostDrawerWidthCss;

  if (stackDrawersCount.value > 0) {
    const lastIndex = stackDrawersCount.value - 1;
    if (index.value === lastIndex) {
      result = props.topMostDrawerWidthCss;
    } else {
      result = "w-full";
    }
  }

  return result;
});
const stackDrawerInstanceContext = {
  close,
};
function open() {
  if (context) {
    index.value = context.register(stackDrawerInstanceContext);
  }
  slidePanel.value?.open();
}
function close() {
  if (context) {
    context.unregister(stackDrawerInstanceContext);
  }
  slidePanel.value?.close();
}

defineExpose({
  open,
  close,
});
</script>
<template>
  <Teleport :to="teleportTo">
    <SlidePanel
      ref="slidePanel"
      side="right"
      class="bg-white transition-[width_transform] duration-300"
      :class="[drawerCss, drawerWidth]"
      @backdrop-clicked="close"
      @closing="$emit('closing')"
      @closed="$emit('closed')"
    >
      <slot :close="close"></slot>
    </SlidePanel>
  </Teleport>
</template>

As we want to allow consumer of StackDrawer to be able to customize the drawer’s width, we expose it as an optional prop: topMostDrawerWidthCss. The default value is "w-full sm:w-[calc(100%-8rem)] md:w-[calc(100%-16rem)]". To summarize this TailwindCSS syntax, the peek width is 8rem when it is at the sm screen breakpoint and 16rem when it is at the md screen breakpoint, however when it is at the mobile screen size, it will take up the entire screen. You can experience it when you reduce the screen size in the demo.

The other parts of the code in StackDrawer is also pretty straightfoward: registering the drawer when opened and unregistering the drawer when closed, as well as exposing these methods using defineExpose().

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

const stackDrawerInstanceContext = {
  close,
};

function open() {
    if (context) {
      index.value = context.register(stackedDrawerInstanceContext);
    }
    slidePanel.value?.open();
  }

function close() {
  if (context) {
    context.unregister(stackedDrawerInstanceContext);
  }
  slidePanel.value?.close();
}

defineExpose({
  open,
  close,
});
</script>

Route-aware drawers

In addition to having the drawers stacked when we dynamically import the StackDrawer component, we might also want them to react to routes. With the help of vue-router, we create a reusable component call AppDrawer to house a Back button to close the StackDrawer and return to previous route, as well as opening it when we directly navigate to a specific route in the browser. Here is the AppDrawer component.

<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { RouteLocationRaw, useRouter } from "vue-router";

import BackButton from "./BackButton.vue";
import StackDrawer from "./StackDrawer.vue";

const props = defineProps<{
  parentRoute?: RouteLocationRaw;
}>();

const slots = defineSlots<{
  header(props: { back: () => void }): any;
  default(props: { back: () => void }): any;
  footer(props: { back: () => void }): any;
}>();
const router = useRouter();
const stackDrawer = ref<InstanceType<typeof StackDrawer>>();

const hasFooter = computed(() => !!slots.footer);

function closed() {
  if (props.parentRoute) {
    router.push(props.parentRoute);
  }
}

onMounted(() => {
  stackDrawer.value?.open();
});
</script>
<template>
  <StackDrawer
    ref="stackDrawer"
    v-slot="{ close }"
    drawer-css="shadow-l-lg"
    @closed="closed"
  >
    <div
      class="grid h-full max-h-full grid-rows-[min-content_1fr_min-content] items-start"
    >
      <div class="flex items-center gap-8 bg-slate-100 px-6 py-4 shadow-md">
        <BackButton @click="close"></BackButton>
        <slot name="header" :back="close"></slot>
      </div>
      <div class="h-full max-h-full min-h-0 overflow-auto">
        <slot :back="close"></slot>
      </div>

      <div v-if="hasFooter" class="bg-slate-100 px-6 py-4 shadow-t-md">
        <slot name="footer" :back="close"></slot>
      </div>
    </div>
  </StackDrawer>
</template>

Note that when we execute the Back command, we only update the route after the StackDrawer has done sliding out of the screen and not as soon as the Back command is invoked. This is to allow the StackDrawer to finish animating the drawer, or else the slide out animation will not be shown and removes the component immediately when route changes.

Using AppDrawer

In our Page component, ie. the top-level page component referred in the routes definition, we can just use the AppDrawer component and provide the content in it’s default slot.

<script setup lang="ts">
import AppDrawer from "@/components/AppDrawer.vue";
import RouterLinkButton from "@/components/RouterLinkButton.vue";
</script>
<template>
  <AppDrawer :parent-route="{ name: 'home' }">
    <template #header>
      <h1>Products</h1>
    </template>

    <div class="grid gap-2 px-6 py-4">
      <div class="flex gap-4">
        <RouterLinkButton
          :to="{ name: 'product', params: { id: 1 } }"
          class="flex items-center gap-1"
          >Open Product 1 Drawer
        </RouterLinkButton>
        <RouterLinkButton
          :to="{ name: 'product', params: { id: 2 } }"
          class="flex items-center gap-1"
          >Open Product 2 Drawer
        </RouterLinkButton>
      </div>
    </div>
  </AppDrawer>

  <router-view />
</template>

The nice thing about using the AppDrawer dynamically like this in the page component or any child-components is the fact that we don’t need to know how many AppDrawers are already opened, the StackDrawersGlobalContext will keep track for us.

The <router-view /> component defined at the end of the template markup is to support the child route.

Conclusion

I find using StackDrawers is a pretty good way to navigate layers of information consistently, especially in a deeply nested page-route scenarios.