This adds a reusable side drawer component (`components-next/drawer/Drawer.vue`), similar in spirit to Dialog and Popover, so drawers across the app share one implementation. The drawer renders as a floating card anchored to the inline-end edge with a slide-in/slide-out animation, and handles the backdrop, Escape key, click-outside, and focus restore. The layout inside is fully slot-driven, with a `close` function passed through the slot. The Captain overview and report drilldown drawers now use this component, and the Captain document details view moved from a centered dialog to this drawer. ## Closes CW-7757 ## What changed - New `Drawer.vue`: teleported floating card with backdrop, RTL-aware slide transition, focus management, and a default slot receiving `close`; emits `afterLeave` so consumers mounted with `v-if` can unmount after the exit animation - `AssistantDrilldownDrawer` and `ReportDrilldownDrawer` refactored to consume it, keeping their own headers and content - `DocumentDetails` migrated from Dialog to the drawer, dropping the imperative `dialogRef.open()` plumbing and fixed-height inner scroll areas - Shared `DRAWER.CLOSE` i18n key replaces the per-drawer close labels https://github.com/user-attachments/assets/58db6c34-8b6f-46e2-bdb1-f2d3675f1a94 --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin <iamsivin@gmail.com>
164 lines
4.7 KiB
Vue
164 lines
4.7 KiB
Vue
<script setup>
|
|
import { computed, onBeforeUnmount, ref } from 'vue';
|
|
import { onKeyStroke, useScrollLock } from '@vueuse/core';
|
|
import Button from 'dashboard/components-next/button/Button.vue';
|
|
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
|
|
|
const props = defineProps({
|
|
title: {
|
|
type: String,
|
|
default: '',
|
|
},
|
|
description: {
|
|
type: String,
|
|
default: '',
|
|
},
|
|
width: {
|
|
type: String,
|
|
default: 'xl',
|
|
validator: value => ['md', 'lg', 'xl', '2xl', '3xl'].includes(value),
|
|
},
|
|
closeOnClickOutside: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
});
|
|
|
|
// `afterLeave` fires once the slide-out transition finishes, so consumers
|
|
// mounted with v-if can wait for it before unmounting the panel.
|
|
const emit = defineEmits(['close', 'afterLeave']);
|
|
|
|
const MAX_WIDTH_CLASSES = {
|
|
md: 'max-w-md',
|
|
lg: 'max-w-lg',
|
|
xl: 'max-w-xl',
|
|
'2xl': 'max-w-2xl',
|
|
'3xl': 'max-w-3xl',
|
|
};
|
|
|
|
const isOpen = ref(false);
|
|
const panelRef = ref(null);
|
|
const isScrollLocked = useScrollLock(document.body);
|
|
|
|
let previousActiveElement = null;
|
|
|
|
const maxWidthClass = computed(() => MAX_WIDTH_CLASSES[props.width]);
|
|
|
|
const open = () => {
|
|
if (isOpen.value) return;
|
|
previousActiveElement =
|
|
document.activeElement instanceof HTMLElement
|
|
? document.activeElement
|
|
: null;
|
|
isOpen.value = true;
|
|
};
|
|
|
|
// Locking the page and moving focus both force layout. Doing that in the frame the panel mounts
|
|
// in delays the slide by a frame, so it waits until the panel has arrived.
|
|
const onAfterEnter = () => {
|
|
isScrollLocked.value = true;
|
|
panelRef.value?.focus();
|
|
};
|
|
|
|
const close = () => {
|
|
if (!isOpen.value) return;
|
|
isOpen.value = false;
|
|
isScrollLocked.value = false;
|
|
if (previousActiveElement?.isConnected) previousActiveElement.focus();
|
|
previousActiveElement = null;
|
|
emit('close');
|
|
};
|
|
|
|
const onOverlayClick = () => {
|
|
if (props.closeOnClickOutside) close();
|
|
};
|
|
|
|
onKeyStroke('Escape', event => {
|
|
if (!isOpen.value) return;
|
|
// A dialog opened on top of the panel (e.g. the editor's link prompt) handles Escape itself.
|
|
if (document.querySelector('dialog[open]')) return;
|
|
|
|
event.preventDefault();
|
|
close();
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
isScrollLocked.value = false;
|
|
});
|
|
|
|
defineExpose({ open, close });
|
|
</script>
|
|
|
|
<template>
|
|
<TeleportWithDirection to="body">
|
|
<Transition
|
|
enter-active-class="transition-opacity duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
|
enter-from-class="opacity-0"
|
|
leave-active-class="transition-opacity duration-200 ease-in"
|
|
leave-to-class="opacity-0"
|
|
>
|
|
<div
|
|
v-if="isOpen"
|
|
class="fixed inset-0 z-50 bg-n-alpha-black1"
|
|
role="presentation"
|
|
@click="onOverlayClick"
|
|
/>
|
|
</Transition>
|
|
<Transition
|
|
enter-active-class="transition-transform duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
|
enter-from-class="translate-x-[calc(100%+0.75rem)] rtl:translate-x-[calc(-100%-0.75rem)]"
|
|
leave-active-class="transition-transform duration-200 ease-in"
|
|
leave-to-class="translate-x-[calc(100%+0.75rem)] rtl:translate-x-[calc(-100%-0.75rem)]"
|
|
@after-enter="onAfterEnter"
|
|
@after-leave="emit('afterLeave')"
|
|
>
|
|
<aside
|
|
v-if="isOpen"
|
|
ref="panelRef"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
:aria-label="title"
|
|
tabindex="-1"
|
|
class="fixed z-50 flex flex-col w-[calc(100%-1.5rem)] overflow-hidden rounded-xl shadow-lg outline outline-1 outline-n-container inset-y-3 end-3 bg-n-solid-1 will-change-transform"
|
|
:class="maxWidthClass"
|
|
>
|
|
<header
|
|
class="flex justify-between flex-shrink-0 gap-4 px-6 py-5 border-b border-n-weak"
|
|
:class="$slots.header ? 'items-start' : 'items-center'"
|
|
>
|
|
<slot name="header">
|
|
<div class="min-w-0">
|
|
<h3 class="text-base font-medium truncate text-n-slate-12">
|
|
{{ title }}
|
|
</h3>
|
|
<p v-if="description" class="mt-1 mb-0 text-sm text-n-slate-11">
|
|
{{ description }}
|
|
</p>
|
|
</div>
|
|
</slot>
|
|
<div class="flex items-center gap-1 shrink-0 -me-2">
|
|
<slot name="header-actions" />
|
|
<Button
|
|
ghost
|
|
slate
|
|
sm
|
|
icon="i-lucide-x"
|
|
:aria-label="$t('GENERAL.CLOSE')"
|
|
@click="close"
|
|
/>
|
|
</div>
|
|
</header>
|
|
<div class="flex-1 min-h-0 px-6 py-5 overflow-y-auto">
|
|
<slot />
|
|
</div>
|
|
<footer
|
|
v-if="$slots.footer"
|
|
class="flex-shrink-0 px-6 py-4 border-t border-n-weak"
|
|
>
|
|
<slot name="footer" />
|
|
</footer>
|
|
</aside>
|
|
</Transition>
|
|
</TeleportWithDirection>
|
|
</template>
|