feat: add generic side drawer component [CW-7757] (#15188)

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>
This commit is contained in:
Shivam Mishra
2026-07-31 15:02:59 +05:30
committed by GitHub
parent 7981e2cc75
commit bc7ae88d5e
10 changed files with 251 additions and 389 deletions

View File

@@ -30,8 +30,9 @@ const captainDocument = {
pdf_document: false,
};
const DialogStub = {
name: 'Dialog',
const SidePanelStub = {
name: 'SidePanel',
methods: { open() {}, close() {} },
template: '<div><slot /></div>',
};
@@ -59,7 +60,7 @@ describe('DocumentDetails', () => {
global: {
directives: { dompurifyHtml: {} },
stubs: {
Dialog: DialogStub,
SidePanel: SidePanelStub,
TabBar: TabBarStub,
PaginationFooter: PaginationFooterStub,
},

View File

@@ -12,7 +12,7 @@ import {
getDocumentDisplayPath,
} from 'shared/helpers/documentHelper';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import SidePanel from 'dashboard/components-next/side-panel/SidePanel.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
@@ -33,7 +33,9 @@ const TAB_KEYS = {
const RESPONSES_PER_PAGE = 25;
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
// The parent mounts this component with v-if, so the panel opens on mount and
// the parent unmounts it only after the slide-out finishes (afterLeave).
const panelRef = ref(null);
const documentDetails = computed(() => props.captainDocument);
const showRawContent = ref(false);
const activeTabIndex = ref(0);
@@ -126,9 +128,9 @@ const syncedAtLabel = computed(() => {
);
});
const handleClose = () => {
emit('close');
};
const documentTitle = computed(
() => documentDetails.value.name || documentDetails.value.external_link
);
const handleCopyContent = async () => {
try {
@@ -156,22 +158,18 @@ const handlePageChange = page => {
};
onMounted(() => {
panelRef.value.open();
fetchResponses();
});
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="documentDetails.name || documentDetails.external_link"
<SidePanel
ref="panelRef"
:title="documentTitle"
:description="t('CAPTAIN.DOCUMENTS.DETAILS.DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
overflow-y-auto
width="3xl"
@close="handleClose"
@after-leave="emit('close')"
>
<div
v-if="isFetching"
@@ -230,7 +228,7 @@ defineExpose({ dialogRef });
@tab-changed="handleTabChanged"
/>
<div class="h-[32rem] overflow-y-auto">
<div>
<section
v-if="activeTabKey === TAB_KEYS.CONTENT"
class="flex flex-col gap-3"
@@ -313,7 +311,7 @@ defineExpose({ dialogRef });
</div>
<div
v-else
class="h-[26rem] overflow-y-auto rounded-lg border border-n-weak bg-n-alpha-1 p-4"
class="rounded-lg border border-n-weak bg-n-alpha-1 p-4"
>
<pre
v-if="showRawContent || isUnreadableContent"
@@ -370,5 +368,5 @@ defineExpose({ dialogRef });
</section>
</div>
</div>
</Dialog>
</SidePanel>
</template>

View File

@@ -1,13 +1,12 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { useEventListener } from '@vueuse/core';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import CaptainAssistant from 'dashboard/api/captain/assistant';
import { useReportDrilldown } from 'dashboard/routes/dashboard/settings/reports/composables/useReportDrilldown';
import ReportDrilldownCard from 'dashboard/routes/dashboard/settings/reports/components/ReportDrilldownCard.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
import SidePanel from 'dashboard/components-next/side-panel/SidePanel.vue';
const props = defineProps({
open: { type: Boolean, default: false },
@@ -20,8 +19,8 @@ const props = defineProps({
const emit = defineEmits(['close']);
const panelRef = ref(null);
const { t } = useI18n();
const drawerRef = ref(null);
const {
records,
meta,
@@ -35,9 +34,6 @@ const {
loadMore,
} = useReportDrilldown(params => CaptainAssistant.getDrilldown(params));
let previousActiveElement = null;
const isOpen = computed(() => props.open);
const title = computed(() => props.metricName || '');
const subtitle = computed(() => {
@@ -50,32 +46,6 @@ const subtitle = computed(() => {
const recordKey = record => `${record.conversation?.id}-${record.occurred_at}`;
const restoreFocus = () => {
if (previousActiveElement?.isConnected) {
previousActiveElement.focus();
}
previousActiveElement = null;
};
const closeDrawer = () => {
close();
emit('close');
restoreFocus();
};
const rememberActiveElement = () => {
if (previousActiveElement) return;
previousActiveElement =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
};
const focusDrawer = () => {
nextTick(() => drawerRef.value?.focus());
};
const fetchDrilldown = () => {
if (!props.metric || !props.assistantId) return;
@@ -86,32 +56,18 @@ const fetchDrilldown = () => {
});
};
const onKeydown = event => {
if (!isOpen.value) return;
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
closeDrawer();
}
};
useEventListener(document, 'keydown', onKeydown);
watch(
() => props.open,
isDrawerOpen => {
if (!isDrawerOpen) {
panelRef.value?.close();
close();
restoreFocus();
return;
}
rememberActiveElement();
panelRef.value?.open();
fetchDrilldown();
focusDrawer();
},
{ immediate: true }
}
);
watch(
@@ -127,108 +83,70 @@ watch(
watch(
() => props.assistantId,
() => {
if (props.open) closeDrawer();
if (props.open) emit('close');
}
);
onBeforeUnmount(() => {
restoreFocus();
});
</script>
<template>
<TeleportWithDirection to="body">
<Transition name="report-drilldown-fade">
<div
v-if="isOpen"
class="fixed inset-0 z-50 bg-black/30"
role="presentation"
@click.self="closeDrawer"
>
<aside
ref="drawerRef"
class="fixed inset-y-0 end-0 flex w-full max-w-xl flex-col bg-n-solid-1 shadow-xl outline outline-1 outline-n-container"
role="dialog"
aria-modal="true"
:aria-label="title"
tabindex="-1"
<SidePanel ref="panelRef" :title="title" width="xl" @close="emit('close')">
<template #header>
<div class="min-w-0">
<h3 class="truncate text-base font-medium text-n-slate-12">
{{ title }}
</h3>
<p
v-if="metricValue"
class="mt-1 text-xl font-semibold text-n-slate-12"
>
<header
class="flex items-start justify-between gap-4 border-b border-n-weak px-6 py-5"
>
<div class="min-w-0">
<h2 class="truncate text-base font-medium text-n-slate-12">
{{ title }}
</h2>
<p
v-if="metricValue"
class="mt-1 text-xl font-semibold text-n-slate-12"
>
{{ metricValue }}
</p>
<div
class="text-sm text-n-slate-11"
:class="{
'mt-2': metricValue,
'mt-1': !metricValue,
}"
>
{{ subtitle }}
</div>
</div>
<Button
ghost
slate
size="sm"
icon="i-ph-x"
:aria-label="$t('CAPTAIN.OVERVIEW.DRILLDOWN.CLOSE')"
@click="closeDrawer"
/>
</header>
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-3">
<div
v-if="isFetching"
class="flex h-40 items-center justify-center"
>
<Spinner />
</div>
<div
v-else-if="hasError"
class="flex h-40 items-center justify-center text-sm text-n-ruby-11"
>
{{ $t('CAPTAIN.OVERVIEW.DRILLDOWN.ERROR') }}
</div>
<div
v-else-if="!hasRecords"
class="flex h-40 items-center justify-center text-sm text-n-slate-10"
>
{{ $t('CAPTAIN.OVERVIEW.DRILLDOWN.EMPTY') }}
</div>
<div v-else class="flex flex-col gap-2">
<ReportDrilldownCard
v-for="record in records"
:key="recordKey(record)"
:record="record"
/>
<Button
v-if="hasMore"
faded
slate
size="sm"
class="mx-auto mt-2"
:label="$t('CAPTAIN.OVERVIEW.DRILLDOWN.LOAD_MORE')"
:is-loading="isFetchingMore"
@click="loadMore"
/>
</div>
</div>
</aside>
{{ metricValue }}
</p>
<div
class="text-sm text-n-slate-11"
:class="{
'mt-2': metricValue,
'mt-1': !metricValue,
}"
>
{{ subtitle }}
</div>
</div>
</Transition>
</TeleportWithDirection>
</template>
<div v-if="isFetching" class="flex h-40 items-center justify-center">
<Spinner />
</div>
<div
v-else-if="hasError"
class="flex h-40 items-center justify-center text-sm text-n-ruby-11"
>
{{ $t('CAPTAIN.OVERVIEW.DRILLDOWN.ERROR') }}
</div>
<div
v-else-if="!hasRecords"
class="flex h-40 items-center justify-center text-sm text-n-slate-10"
>
{{ $t('CAPTAIN.OVERVIEW.DRILLDOWN.EMPTY') }}
</div>
<div v-else class="flex flex-col gap-2">
<ReportDrilldownCard
v-for="record in records"
:key="recordKey(record)"
:record="record"
/>
<Button
v-if="hasMore"
faded
slate
size="sm"
class="mx-auto mt-2"
:label="$t('CAPTAIN.OVERVIEW.DRILLDOWN.LOAD_MORE')"
:is-loading="isFetchingMore"
@click="loadMore"
/>
</div>
</SidePanel>
</template>

View File

@@ -15,8 +15,8 @@ const props = defineProps({
},
width: {
type: String,
default: 'lg',
validator: value => ['sm', 'md', 'lg', 'xl'].includes(value),
default: 'xl',
validator: value => ['md', 'lg', 'xl', '2xl', '3xl'].includes(value),
},
closeOnClickOutside: {
type: Boolean,
@@ -24,13 +24,16 @@ const props = defineProps({
},
});
const emit = defineEmits(['close']);
// `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 = {
sm: 'max-w-md',
md: 'max-w-xl',
lg: 'max-w-3xl',
xl: 'max-w-5xl',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
'3xl': 'max-w-3xl',
};
const isOpen = ref(false);
@@ -103,10 +106,11 @@ defineExpose({ open, close });
</Transition>
<Transition
enter-active-class="transition-transform duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]"
enter-from-class="translate-x-full rtl:-translate-x-full"
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-full rtl:-translate-x-full"
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"
@@ -115,29 +119,34 @@ defineExpose({ open, close });
aria-modal="true"
:aria-label="title"
tabindex="-1"
class="fixed z-50 flex flex-col w-full shadow-xl outline-none inset-y-0 end-0 bg-n-solid-1 will-change-transform"
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 items-center justify-between flex-shrink-0 gap-4 px-6 py-5 border-b border-n-weak"
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'"
>
<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>
<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>
<Button
ghost
slate
sm
icon="i-lucide-x"
class="-me-2"
:aria-label="$t('GENERAL.CLOSE')"
@click="close"
/>
</header>
<div class="flex-1 min-h-0 px-6 py-5 overflow-y-auto">
<slot />

View File

@@ -447,7 +447,6 @@
}
},
"DRILLDOWN": {
"CLOSE": "Close details",
"EMPTY": "No records found for this metric.",
"ERROR": "Could not load records. Please try again.",
"LOAD_MORE": "Load more",

View File

@@ -129,7 +129,6 @@
"ERROR": "Could not load records. Please try again.",
"ADMIN_ONLY": "Only administrators can drill down into report records.",
"LOAD_MORE": "Load more",
"CLOSE": "Close details",
"PREVIOUS_BUCKET": "Previous bar",
"NEXT_BUCKET": "Next bar",
"UNKNOWN_CONTACT": "Unknown contact",

View File

@@ -54,11 +54,9 @@ const handleDelete = () => {
const showDocumentDetails = ref(false);
const showCreateDialog = ref(false);
const createDocumentDialog = ref(null);
const documentDetailsDialog = ref(null);
const handleShowDocumentDetails = () => {
showDocumentDetails.value = true;
nextTick(() => documentDetailsDialog.value.dialogRef.open());
};
const handleCreateDocument = () => {
showCreateDialog.value = true;
@@ -430,7 +428,6 @@ onUnmounted(() => {
<DocumentDetails
v-if="showDocumentDetails"
ref="documentDetailsDialog"
:captain-document="selectedDocument"
@close="handleDocumentDetailsClose"
/>

View File

@@ -291,7 +291,7 @@ defineExpose({ open, close });
</script>
<template>
<SidePanel ref="panelRef" width="lg" :title="$t(titleKey)">
<SidePanel ref="panelRef" width="3xl" :title="$t(titleKey)">
<div v-if="automation" class="flex flex-col w-full gap-6">
<div class="flex flex-col">
<woot-input

View File

@@ -1,11 +1,11 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { useEventListener } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { formatTime } from '@chatwoot/utils';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
import SidePanel from 'dashboard/components-next/side-panel/SidePanel.vue';
import { useReportDrilldown } from '../composables/useReportDrilldown';
import ReportDrilldownCard from './ReportDrilldownCard.vue';
@@ -29,8 +29,8 @@ const props = defineProps({
const emit = defineEmits(['close', 'navigate']);
const panelRef = ref(null);
const { t } = useI18n();
const drawerRef = ref(null);
const {
records,
meta,
@@ -44,10 +44,6 @@ const {
loadMore,
} = useReportDrilldown();
let previousActiveElement = null;
const isOpen = computed(() => props.open);
const title = computed(() => props.metricName || '');
const bucketValue = computed(() => {
@@ -100,37 +96,11 @@ const subtitle = computed(() =>
.join(' ⋅ ')
);
const restoreFocus = () => {
if (previousActiveElement?.isConnected) {
previousActiveElement.focus();
}
previousActiveElement = null;
};
const closeDrawer = () => {
close();
emit('close');
restoreFocus();
};
const recordKey = record =>
`${record.record_type}-${record.message?.id || record.conversation?.id}-${
record.occurred_at
}`;
const rememberActiveElement = () => {
if (previousActiveElement) return;
previousActiveElement =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
};
const focusDrawer = () => {
nextTick(() => drawerRef.value?.focus());
};
const fetchDrilldown = () => {
openDrilldown({
metric: props.metric,
@@ -152,13 +122,9 @@ const navigate = direction => {
};
const onKeydown = event => {
if (!isOpen.value) return;
if (!props.open) return;
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
closeDrawer();
} else if (event.key === 'ArrowLeft') {
if (event.key === 'ArrowLeft') {
navigate(-1);
} else if (event.key === 'ArrowRight') {
navigate(1);
@@ -171,16 +137,14 @@ watch(
() => props.open,
isDrawerOpen => {
if (!isDrawerOpen) {
panelRef.value?.close();
close();
restoreFocus();
return;
}
rememberActiveElement();
panelRef.value?.open();
fetchDrilldown();
focusDrawer();
},
{ immediate: true }
}
);
watch(
@@ -189,127 +153,89 @@ watch(
if (props.open) fetchDrilldown();
}
);
onBeforeUnmount(() => {
restoreFocus();
});
</script>
<template>
<TeleportWithDirection to="body">
<Transition name="report-drilldown-fade">
<div
v-if="isOpen"
class="fixed inset-0 z-50 bg-black/30"
role="presentation"
@click.self="closeDrawer"
>
<aside
ref="drawerRef"
class="fixed inset-y-0 end-0 flex w-full max-w-xl flex-col bg-n-solid-1 shadow-xl outline outline-1 outline-n-container"
role="dialog"
aria-modal="true"
:aria-label="title"
tabindex="-1"
<SidePanel ref="panelRef" :title="title" width="xl" @close="emit('close')">
<template #header>
<div class="min-w-0">
<h3 class="truncate text-base font-medium text-n-slate-12">
{{ title }}
</h3>
<p
v-if="bucketValue"
class="mt-1 text-xl font-semibold text-n-slate-12"
>
<header
class="flex items-start justify-between gap-4 border-b border-n-weak px-6 py-5"
>
<div class="min-w-0">
<h2 class="truncate text-base font-medium text-n-slate-12">
{{ title }}
</h2>
<p
v-if="bucketValue"
class="mt-1 text-xl font-semibold text-n-slate-12"
>
{{ bucketValue }}
</p>
<div
class="text-sm text-n-slate-11"
:class="{
'mt-2': bucketValue,
'mt-1': !bucketValue,
}"
>
{{ subtitle }}
</div>
</div>
<div class="flex shrink-0 items-center gap-1">
<Button
ghost
slate
size="sm"
icon="i-ph-caret-left"
class="rtl:rotate-180"
:disabled="!canPrev"
:aria-label="$t('REPORT.DRILLDOWN.PREVIOUS_BUCKET')"
@click="navigate(-1)"
/>
<Button
ghost
slate
size="sm"
icon="i-ph-caret-right"
class="rtl:rotate-180"
:disabled="!canNext"
:aria-label="$t('REPORT.DRILLDOWN.NEXT_BUCKET')"
@click="navigate(1)"
/>
<Button
ghost
slate
size="sm"
icon="i-ph-x"
:aria-label="$t('REPORT.DRILLDOWN.CLOSE')"
@click="closeDrawer"
/>
</div>
</header>
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-3">
<div
v-if="isFetching"
class="flex h-40 items-center justify-center"
>
<Spinner />
</div>
<div
v-else-if="hasError"
class="flex h-40 items-center justify-center text-sm text-n-ruby-11"
>
{{ $t('REPORT.DRILLDOWN.ERROR') }}
</div>
<div
v-else-if="!hasRecords"
class="flex h-40 items-center justify-center text-sm text-n-slate-10"
>
{{ $t('REPORT.DRILLDOWN.EMPTY') }}
</div>
<div v-else class="flex flex-col gap-2">
<ReportDrilldownCard
v-for="record in records"
:key="recordKey(record)"
:record="record"
/>
<Button
v-if="hasMore"
faded
slate
size="sm"
class="mx-auto mt-2"
:label="$t('REPORT.DRILLDOWN.LOAD_MORE')"
:is-loading="isFetchingMore"
@click="loadMore"
/>
</div>
</div>
</aside>
{{ bucketValue }}
</p>
<div
class="text-sm text-n-slate-11"
:class="{
'mt-2': bucketValue,
'mt-1': !bucketValue,
}"
>
{{ subtitle }}
</div>
</div>
</Transition>
</TeleportWithDirection>
</template>
<template #header-actions>
<Button
ghost
slate
size="sm"
icon="i-ph-caret-left"
class="rtl:rotate-180"
:disabled="!canPrev"
:aria-label="$t('REPORT.DRILLDOWN.PREVIOUS_BUCKET')"
@click="navigate(-1)"
/>
<Button
ghost
slate
size="sm"
icon="i-ph-caret-right"
class="rtl:rotate-180"
:disabled="!canNext"
:aria-label="$t('REPORT.DRILLDOWN.NEXT_BUCKET')"
@click="navigate(1)"
/>
</template>
<div v-if="isFetching" class="flex h-40 items-center justify-center">
<Spinner />
</div>
<div
v-else-if="hasError"
class="flex h-40 items-center justify-center text-sm text-n-ruby-11"
>
{{ $t('REPORT.DRILLDOWN.ERROR') }}
</div>
<div
v-else-if="!hasRecords"
class="flex h-40 items-center justify-center text-sm text-n-slate-10"
>
{{ $t('REPORT.DRILLDOWN.EMPTY') }}
</div>
<div v-else class="flex flex-col gap-2">
<ReportDrilldownCard
v-for="record in records"
:key="recordKey(record)"
:record="record"
/>
<Button
v-if="hasMore"
faded
slate
size="sm"
class="mx-auto mt-2"
:label="$t('REPORT.DRILLDOWN.LOAD_MORE')"
:is-loading="isFetchingMore"
@click="loadMore"
/>
</div>
</SidePanel>
</template>

View File

@@ -73,9 +73,11 @@ describe('ReportDrilldownDrawer.vue', () => {
},
];
const mountDrawer = options =>
mount(ReportDrilldownDrawer, {
props: { open: true, ...request, ...options?.props },
// The panel opens imperatively from a watch on `open`, so mount closed (as
// the parents do) and open via setProps.
const mountDrawer = async options => {
const wrapper = mount(ReportDrilldownDrawer, {
props: { ...request, ...options?.props, open: false },
attachTo: options?.attachTo,
global: {
stubs: {
@@ -101,6 +103,9 @@ describe('ReportDrilldownDrawer.vue', () => {
},
},
});
await wrapper.setProps({ open: true });
return wrapper;
};
beforeEach(() => {
ReportsAPI.getDrilldown.mockResolvedValue({
@@ -122,7 +127,7 @@ describe('ReportDrilldownDrawer.vue', () => {
});
it('loads and renders drilldown cards for the request', async () => {
const wrapper = mountDrawer();
const wrapper = await mountDrawer();
await flushPromises();
expect(ReportsAPI.getDrilldown).toHaveBeenCalledWith(
@@ -138,7 +143,7 @@ describe('ReportDrilldownDrawer.vue', () => {
});
it('shows the bucket aggregate value for average metrics', async () => {
const wrapper = mountDrawer({
const wrapper = await mountDrawer({
props: {
metric: 'avg_first_response_time',
metricName: 'First response time',
@@ -163,7 +168,7 @@ describe('ReportDrilldownDrawer.vue', () => {
payload,
},
});
const wrapper = mountDrawer({
const wrapper = await mountDrawer({
props: {
metric: 'reply_time',
isAverageMetric: true,
@@ -188,7 +193,7 @@ describe('ReportDrilldownDrawer.vue', () => {
payload,
},
});
const wrapper = mountDrawer({
const wrapper = await mountDrawer({
props: {
metric: 'avg_first_response_time',
isAverageMetric: true,
@@ -202,7 +207,7 @@ describe('ReportDrilldownDrawer.vue', () => {
});
it('shows the plain count as the bucket value for count metrics', async () => {
const wrapper = mountDrawer({ props: { bucketValue: 128 } });
const wrapper = await mountDrawer({ props: { bucketValue: 128 } });
await flushPromises();
expect(wrapper.text()).toContain('128');
@@ -221,7 +226,7 @@ describe('ReportDrilldownDrawer.vue', () => {
payload,
},
});
const wrapper = mountDrawer({
const wrapper = await mountDrawer({
props: { metric: 'conversations_count', bucketValue: 5 },
});
await flushPromises();
@@ -242,7 +247,7 @@ describe('ReportDrilldownDrawer.vue', () => {
payload,
},
});
const wrapper = mountDrawer({
const wrapper = await mountDrawer({
props: { metric: 'resolutions_count', bucketValue: 8 },
});
await flushPromises();
@@ -251,16 +256,18 @@ describe('ReportDrilldownDrawer.vue', () => {
});
it('anchors the drawer to the inline-end edge so it flips in RTL', async () => {
const wrapper = mountDrawer();
const wrapper = await mountDrawer();
await flushPromises();
const drawer = wrapper.get('[role="dialog"]');
expect(drawer.classes()).toContain('end-0');
expect(drawer.classes()).not.toContain('right-0');
expect(drawer.classes()).toContain('end-3');
expect(drawer.classes()).not.toContain('right-3');
});
it('flips the navigation caret icons in RTL', async () => {
const wrapper = mountDrawer({ props: { canPrev: true, canNext: true } });
const wrapper = await mountDrawer({
props: { canPrev: true, canNext: true },
});
await flushPromises();
expect(
@@ -272,16 +279,16 @@ describe('ReportDrilldownDrawer.vue', () => {
});
it('emits close when the drawer close button is clicked', async () => {
const wrapper = mountDrawer();
const wrapper = await mountDrawer();
await flushPromises();
await wrapper.get('[aria-label="REPORT.DRILLDOWN.CLOSE"]').trigger('click');
await wrapper.get('[aria-label="GENERAL.CLOSE"]').trigger('click');
expect(wrapper.emitted('close')).toBeTruthy();
});
it('emits navigate when the next button is clicked', async () => {
const wrapper = mountDrawer({ props: { canNext: true } });
const wrapper = await mountDrawer({ props: { canNext: true } });
await flushPromises();
await wrapper
@@ -292,7 +299,7 @@ describe('ReportDrilldownDrawer.vue', () => {
});
it('does not emit navigate past the available range', async () => {
const wrapper = mountDrawer({ props: { canPrev: false } });
const wrapper = await mountDrawer({ props: { canPrev: false } });
await flushPromises();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
@@ -303,13 +310,16 @@ describe('ReportDrilldownDrawer.vue', () => {
it('moves focus into the drawer when opened', async () => {
const target = document.createElement('div');
document.body.appendChild(target);
const wrapper = mountDrawer({ attachTo: target });
const wrapper = await mountDrawer({ attachTo: target });
await flushPromises();
await nextTick();
expect(document.activeElement).toBe(
wrapper.find('[role="dialog"]').element
);
// The panel focuses itself once the enter transition settles (after-enter
// resolves on animation frames), so poll instead of awaiting microtasks.
await vi.waitFor(() => {
expect(document.activeElement).toBe(
wrapper.find('[role="dialog"]').element
);
});
wrapper.unmount();
target.remove();
@@ -318,11 +328,14 @@ describe('ReportDrilldownDrawer.vue', () => {
it('closes on Escape even when focus is outside the drawer', async () => {
const target = document.createElement('div');
document.body.appendChild(target);
const wrapper = mountDrawer({ attachTo: target });
const wrapper = await mountDrawer({ attachTo: target });
await flushPromises();
document.body.focus();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
// Bubbles like a real key press so it reaches the panel's window listener.
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
);
expect(wrapper.emitted('close')).toBeTruthy();
@@ -337,11 +350,13 @@ describe('ReportDrilldownDrawer.vue', () => {
document.body.appendChild(target);
opener.focus();
const wrapper = mountDrawer({ attachTo: target });
const wrapper = await mountDrawer({ attachTo: target });
await flushPromises();
await nextTick();
await wrapper.get('[aria-label="REPORT.DRILLDOWN.CLOSE"]').trigger('click');
await wrapper.get('[aria-label="GENERAL.CLOSE"]').trigger('click');
await wrapper.setProps({ open: false });
await nextTick();
expect(document.activeElement).toBe(opener);