chore: Update feedback fixes

This commit is contained in:
iamsivin
2026-01-16 01:21:51 +05:30
parent 8e5e5692fd
commit ed590a0684
27 changed files with 346 additions and 276 deletions
@@ -6,6 +6,10 @@ import LabelItem from 'dashboard/components-next/label/LabelItem.vue';
import AddLabel from 'dashboard/components-next/label/AddLabel.vue';
const props = defineProps({
wrapperRef: {
type: Object,
default: null,
},
contactId: {
type: [String, Number],
default: null,
@@ -117,10 +121,7 @@ const handleLabelHover = labelId => {
</script>
<template>
<div
class="flex flex-wrap items-center gap-2 ltr:mr-10 rtl:ml-10"
@mouseleave="handleMouseLeave"
>
<div class="flex flex-wrap items-center gap-2" @mouseleave="handleMouseLeave">
<LabelItem
v-for="label in savedLabels"
:key="label.id"
@@ -132,6 +133,7 @@ const handleLabelHover = labelId => {
/>
<AddLabel
:label-menu-items="labelMenuItems"
:wrapper-ref="wrapperRef"
@update-label="handleLabelAction"
/>
</div>
@@ -126,7 +126,7 @@ const closeMobileSidebar = () => {
<!-- Desktop sidebar -->
<div
v-if="slots.sidebar"
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-5 max-w-md border-l border-n-weak bg-n-surface-2"
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-5 max-w-md ltr:border-l rtl:border-r border-n-weak bg-n-surface-2"
>
<slot name="sidebar" />
</div>
@@ -25,6 +25,8 @@ const emit = defineEmits(['goToContactsList']);
const { t } = useI18n();
const store = useStore();
const wrapperRef = ref(null);
const confirmDeleteContactDialogRef = ref(null);
const avatarFile = ref(null);
@@ -123,7 +125,7 @@ const handleAvatarDelete = async () => {
<template>
<div class="flex flex-col items-start gap-6 pb-6 w-full">
<div class="flex flex-col items-start gap-3 w-full">
<div ref="wrapperRef" class="flex flex-col items-start gap-3 w-full">
<Avatar
:src="avatarSrc || ''"
:name="selectedContact?.name || ''"
@@ -173,7 +175,10 @@ const handleAvatarDelete = async () => {
</span>
</div>
</div>
<ContactLabels :contact-id="selectedContact?.id" />
<ContactLabels
:wrapper-ref="wrapperRef"
:contact-id="selectedContact?.id"
/>
</div>
<div class="flex flex-col items-start gap-6 w-full">
<ContactsForm
@@ -116,7 +116,7 @@ const labelsCountText = computed(() => {
});
}
if (!showAllLabels.value && hiddenLabelsCount.value > 0) {
return `+${hiddenLabelsCount.value}`;
return hiddenLabelsCount.value;
}
return '';
});
@@ -181,8 +181,8 @@ const onShowLabels = e => {
xs
slate
:no-animation="disableToggle"
:icon="labelsCountText ? '' : 'i-lucide-chevron-left'"
class="!py-0 !px-1.5 flex-shrink-0 !rounded-md !bg-n-button-color -outline-offset-1"
:icon="labelsCountText ? 'i-lucide-plus' : 'i-lucide-chevron-left'"
class="!py-0 !px-1.5 flex-shrink-0 !rounded-md !bg-n-button-color -outline-offset-1 !gap-0.5 [&>span:first-child]:!text-n-slate-10 [&>span:last-child]:!text-n-slate-11"
:class="{ 'cursor-default': disableToggle }"
@click="onShowLabels"
/>
@@ -48,11 +48,6 @@ const showSection = computed(() => {
>
<div class="flex-1 min-w-0 flex items-center gap-0.5">
<InboxName v-if="showInboxName" :inbox="inbox" class="min-w-0" />
<div
v-if="showInboxName && hasSlaPolicyId"
class="rounded-lg h-3 w-px bg-n-strong mx-1"
/>
<SLACardLabel v-if="hasSlaPolicyId" ref="slaCardLabel" :chat="chat" />
</div>
<div class="flex items-center gap-1.5 flex-shrink-0">
<CardPriorityIcon
@@ -1,11 +1,12 @@
<script setup>
import { computed } from 'vue';
import { computed, useTemplateRef } from 'vue';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import CardMetaSection from './CardMetaSection.vue';
import CardAvatar from './CardAvatar.vue';
import CardHeader from './CardHeader.vue';
import CardContent from './CardContent.vue';
import CardLabels from './CardLabels.vue';
import SLACardLabel from 'dashboard/components-next/Conversation/Sla/SLACardLabel.vue';
const props = defineProps({
chat: { type: Object, required: true },
@@ -24,6 +25,8 @@ const props = defineProps({
const emit = defineEmits(['selectConversation', 'click', 'contextmenu']);
const slaCardLabel = useTemplateRef('slaCardLabel');
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
const showExpandedPreview = computed(
@@ -37,6 +40,10 @@ const voiceCallData = computed(() => ({
const unreadCount = computed(() => props.chat?.unread_count);
const hasSlaPolicyId = computed(
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
);
const onSelectConversation = checked => {
emit('selectConversation', checked);
};
@@ -103,8 +110,9 @@ const onSelectConversation = checked => {
v-if="showLabelsSection || isInboxView"
:labels="chat.labels"
>
<template v-if="isInboxView" #before>
<template #before>
<CardMetaSection
v-if="isInboxView"
:chat="chat"
:inbox="inbox"
:show-assignee="showAssignee"
@@ -112,6 +120,12 @@ const onSelectConversation = checked => {
:is-labels-empty="chat.labels.length === 0"
inline
/>
<SLACardLabel
v-else-if="hasSlaPolicyId"
ref="slaCardLabel"
data-before-slot
:chat="chat"
/>
</template>
</CardLabels>
</div>
@@ -116,11 +116,23 @@ const selectedModel = computed({
<InboxName v-if="showInboxName" :inbox="inbox" class="min-w-0" />
</div>
<span
class="text-label-small outline outline-1 -outline-offset-1 outline-n-weak px-1.5 rounded-md h-6 text-n-slate-11 min-w-12 flex-shrink-0 inline-flex items-center justify-center truncate"
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
<div
v-tooltip.top="{
content: chat.id,
delay: { show: 500, hide: 0 },
}"
class="h-6 flex items-center gap-1 max-w-20 w-full min-w-0"
>
{{ chat.id }}
</span>
<Icon
icon="i-woot-hash"
class="size-3.5 text-n-slate-10 flex-shrink-0"
/>
<span class="text-label-small text-n-slate-11 truncate">
{{ chat.id }}
</span>
</div>
<CardAvatar
:contact="currentContact"
@@ -5,6 +5,7 @@ import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import SLAPopoverCard from './SLAPopoverCard.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({
chat: {
@@ -35,9 +36,6 @@ const appliedSLA = computed(() => props.chat?.applied_sla);
const slaEvents = computed(() => props.chat?.sla_events);
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const slaTextStyles = computed(() =>
isSlaMissed.value ? 'text-n-ruby-11' : 'text-n-amber-11'
);
const conversation = computed(() => useCamelCase(props.chat, { deep: true }));
@@ -83,16 +81,15 @@ defineExpose({
v-bind="$attrs"
class="relative flex items-center cursor-pointer min-w-fit group"
>
<div class="flex items-center w-full truncate gap-1" :class="slaTextStyles">
<Icon
icon="i-lucide-flame"
class="flex-shrink-0 size-3"
:class="slaTextStyles"
/>
<span class="text-label-small" :class="slaTextStyles">
{{ slaStatus.threshold }}
</span>
</div>
<Label
:label="slaStatus.threshold"
:color="isSlaMissed ? 'ruby' : 'amber'"
compact
>
<template #icon>
<Icon icon="i-lucide-flame" class="flex-shrink-0 size-3.5" />
</template>
</Label>
<SLAPopoverCard
v-if="showSlaPopoverCard"
:sla-missed-events="slaEvents"
@@ -1,17 +1,23 @@
<script setup>
import { ref } from 'vue';
import { ref, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
import { useResizeObserver } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
defineProps({
const props = defineProps({
labelMenuItems: {
type: Array,
default: () => [],
},
wrapperRef: {
type: Object,
default: null,
},
});
const emit = defineEmits(['updateLabel']);
@@ -22,15 +28,54 @@ const showDropdown = ref(false);
const triggerRef = ref(null);
const dropdownRef = ref(null);
const { positionClasses } = useDropdownPosition(
const { position, updatePosition } = useDropdownPosition(
triggerRef,
dropdownRef,
showDropdown
showDropdown,
{ container: props.wrapperRef }
);
const openDropdown = async () => {
showDropdown.value = !showDropdown.value;
if (showDropdown.value) {
await nextTick();
updatePosition();
}
};
const updateLabel = label => {
emit('updateLabel', label);
};
const closeDropdown = () => {
showDropdown.value = false;
};
// Watch labelMenuItems and update position when they change
watch(
() => props.labelMenuItems,
async () => {
if (showDropdown.value) {
await nextTick();
updatePosition();
}
},
{ deep: true }
);
// Watch wrapper container for size changes (hover effects from parent)
useResizeObserver(props.wrapperRef, () => {
if (showDropdown.value) {
updatePosition();
}
});
</script>
<template>
<div class="relative">
<div
v-on-click-outside="[closeDropdown, { ignore: [triggerRef] }]"
class="relative"
>
<Button
ref="triggerRef"
:label="t('LABEL.TAG_BUTTON')"
@@ -39,17 +84,17 @@ const { positionClasses } = useDropdownPosition(
:variant="showDropdown ? 'faded' : 'solid'"
class="font-460 !-outline-offset-1"
icon="i-lucide-plus"
@click="showDropdown = !showDropdown"
@click="openDropdown()"
/>
<DropdownMenu
v-if="showDropdown"
ref="dropdownRef"
v-on-clickaway="() => (showDropdown = false)"
:menu-items="labelMenuItems"
show-search
class="z-[100] w-48 overflow-y-auto max-h-52"
:class="positionClasses"
@action="emit('updateLabel', $event)"
:class="position.class"
:style="position.style"
@action="updateLabel($event)"
>
<template #thumbnail="{ item }">
<div
@@ -47,10 +47,10 @@ const colorClasses = computed(() => COLOR_CLASSES[props.color]);
<template>
<div
:title="labelDescription"
class="gap-1.5 rounded-lg -outline-offset-1 outline outline-1 inline-flex items-center flex-shrink-0"
class="rounded-lg -outline-offset-1 outline outline-1 inline-flex items-center flex-shrink-0"
:class="[
colorClasses,
compact ? 'px-1.5 h-6 gap-1 rounded-md' : 'px-2.5 h-8 rounded-lg',
compact ? 'px-1.5 h-6 gap-1 rounded-md' : 'px-2.5 h-8 gap-1.5 rounded-lg',
]"
>
<span
@@ -24,9 +24,11 @@ const onToggle = () => {
</script>
<template>
<div class="text-sm py-4 grid gap-2">
<div
class="text-sm mx-4 grid gap-2 py-3 rounded-[0.625rem] outline outline-1 outline-n-weak -outline-offset-1"
>
<button
class="flex items-center select-none w-full m-0 cursor-grab justify-between drag-handle px-4 py-0 focus-visible:outline-none outline-none !transition-none"
class="flex items-center select-none w-full m-0 cursor-grab justify-between drag-handle px-3 py-0 focus-visible:outline-none outline-none !transition-none"
@click.stop="onToggle"
>
<h5 class="text-n-slate-12 text-heading-3 mb-0 py-0 pr-2 pl-0">
@@ -47,8 +49,42 @@ const onToggle = () => {
/>
</div>
</button>
<div v-if="isOpen" :class="compact ? 'px-1' : 'px-4'">
<slot />
</div>
<Transition
enter-active-class="transition-[grid-template-rows,opacity,transform] duration-[400ms] ease-[cubic-bezier(0.23,1,0.32,1)]"
enter-from-class="grid-rows-[0fr] opacity-0 scale-[0.96] -translate-y-2"
enter-to-class="grid-rows-[1fr] opacity-100 scale-100 translate-y-0"
leave-active-class="transition-[grid-template-rows,opacity,transform] duration-[250ms] ease-[cubic-bezier(0.4,0,1,1)]"
leave-from-class="grid-rows-[1fr] opacity-100 scale-100"
leave-to-class="grid-rows-[0fr] opacity-0 scale-[0.96] -translate-y-1"
@before-enter="
el => {
el.style.overflow = 'hidden';
el.style.willChange = 'grid-template-rows, opacity, transform';
}
"
@after-enter="
el => {
el.style.overflow = 'visible';
el.style.willChange = 'auto';
}
"
@before-leave="
el => {
el.style.overflow = 'hidden';
el.style.willChange = 'grid-template-rows, opacity, transform';
}
"
@after-leave="
el => {
el.style.willChange = 'auto';
}
"
>
<div v-if="isOpen" class="grid origin-top">
<div class="min-h-0" :class="[compact ? 'px-0' : 'px-3']">
<slot />
</div>
</div>
</Transition>
</div>
</template>
@@ -141,7 +141,7 @@ const maxVisibleFilters = computed(() => {
<div
id="saveFilterTeleportTarget"
class="absolute z-50 top-6"
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
:class="{ 'ltr:left-0 rtl:right-0': isOnExpandedLayout }"
/>
</div>
</template>
@@ -86,22 +86,12 @@ onMounted(() => {
</script>
<template>
<div>
<div v-if="hasIssues" class="pt-3 px-3 pb-2">
<NextButton
sm
slate
icon="i-lucide-plus"
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK_BUTTON')"
@click="openCreateModal"
/>
</div>
<div class="mt-1 px-3">
<div v-if="isLoading" class="flex justify-center p-8">
<Spinner />
</div>
<div v-else-if="!hasIssues" class="mt-2 px-3 w-full">
<div v-else-if="!hasIssues" class="mt-2 w-full">
<SidePanelEmptyState
:message="$t('INTEGRATION_SETTINGS.LINEAR.NO_LINKED_ISSUES')"
/>
@@ -115,16 +105,27 @@ onMounted(() => {
/>
</div>
<div v-else class="max-h-[300px] overflow-y-auto px-2">
<div v-else class="max-h-[300px] overflow-y-auto">
<LinearIssueItem
v-for="linkedIssue in linkedIssues"
:key="linkedIssue.id"
class="px-1 pt-3 pb-4 border-b border-n-weak last:border-b-0"
class="border-b border-n-weak last:border-b-0"
:linked-issue="linkedIssue"
@unlink-issue="unlinkIssue"
/>
</div>
<div v-if="hasIssues" class="pt-3 w-full">
<NextButton
sm
slate
icon="i-lucide-plus"
class="w-full"
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK_BUTTON')"
@click="openCreateModal"
/>
</div>
<woot-modal
v-model:show="shouldShowCreateModal"
:on-close="closeCreateModal"
@@ -44,7 +44,7 @@ const unlinkIssue = () => {
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-4 py-3">
<div class="flex flex-col w-full">
<IssueHeader
:identifier="issue.identifier"
@@ -1,10 +1,8 @@
import { ref, nextTick } from 'vue';
import { useDropdownPosition } from '../useDropdownPosition';
// Mock VueUse composables
vi.mock('@vueuse/core', () => ({
useElementBounding: vi.fn(elementRef => {
// Create reactive refs that update based on element
const top = ref(0);
const right = ref(0);
const bottom = ref(100);
@@ -13,8 +11,7 @@ vi.mock('@vueuse/core', () => ({
const height = ref(50);
const update = vi.fn(() => {
// Update bounds from element when called
if (elementRef.value?.getBoundingClientRect) {
if (elementRef && elementRef.value?.getBoundingClientRect) {
const rect = elementRef.value.getBoundingClientRect();
top.value = rect.top;
right.value = rect.right;
@@ -25,20 +22,9 @@ vi.mock('@vueuse/core', () => ({
}
});
// Initial update
update();
return {
top,
right,
bottom,
left,
width,
height,
x: left,
y: top,
update,
};
return { top, right, bottom, left, width, height, x: left, y: top, update };
}),
useWindowSize: vi.fn(() => ({
width: ref(1024),
@@ -53,7 +39,6 @@ describe('useDropdownPosition', () => {
let querySelectorSpy;
beforeEach(() => {
// Create mock DOM elements
triggerRef = ref({
getBoundingClientRect: () => ({
top: 100,
@@ -78,7 +63,6 @@ describe('useDropdownPosition', () => {
enabled = ref(true);
// Spy on document.querySelector for RTL check
querySelectorSpy = vi.spyOn(document, 'querySelector').mockReturnValue({
getAttribute: () => 'ltr',
});
@@ -88,74 +72,49 @@ describe('useDropdownPosition', () => {
querySelectorSpy.mockRestore();
});
it('should return default position classes when disabled', () => {
it('should return default position when disabled', () => {
enabled.value = false;
const { positionClasses } = useDropdownPosition(
triggerRef,
dropdownRef,
enabled
);
const { position } = useDropdownPosition(triggerRef, dropdownRef, enabled);
expect(positionClasses.value).toBe('top-full mt-2 ltr:left-0 rtl:right-0');
expect(position.value.class).toBe('top-full mt-2');
expect(position.value.style).toEqual({});
});
it('should return default position classes when refs are null', () => {
it('should return default position when refs are null', () => {
triggerRef.value = null;
dropdownRef.value = null;
const { positionClasses } = useDropdownPosition(
triggerRef,
dropdownRef,
enabled
);
const { position } = useDropdownPosition(triggerRef, dropdownRef, enabled);
expect(positionClasses.value).toBe('top-full mt-2 ltr:left-0 rtl:right-0');
expect(position.value.class).toBe('top-full mt-2');
expect(position.value.style).toEqual({ left: '0px' });
});
it('should calculate position when enabled and refs exist', () => {
const { positionClasses } = useDropdownPosition(
triggerRef,
dropdownRef,
enabled
);
const { position } = useDropdownPosition(triggerRef, dropdownRef, enabled);
// Should return some position classes (not the default)
expect(positionClasses.value).toBeTruthy();
expect(typeof positionClasses.value).toBe('string');
expect(position.value.class).toBeTruthy();
expect(typeof position.value.class).toBe('string');
});
it('should position dropdown below trigger when enough space', () => {
// Trigger at top of screen, plenty of space below
// Window height: 768, Trigger bottom: 150
// Window height: 768, Trigger bottom: 150, Dropdown height: 200, Margin: 16
// Space below: 768 - 150 = 618px
// Dropdown height: 200px + SAFE_MARGIN (16px) = 216px
// 618 > 216 = true, so should position below (top-full)
triggerRef.value.getBoundingClientRect = () => ({
top: 100,
right: 250,
bottom: 150,
left: 50,
width: 200,
height: 50,
});
// Required: 200 + 16 = 216px
// 618 >= 216, so position below
const { position } = useDropdownPosition(triggerRef, dropdownRef, enabled);
const { positionClasses } = useDropdownPosition(
triggerRef,
dropdownRef,
enabled
);
expect(positionClasses.value).toContain('top-full');
expect(positionClasses.value).not.toContain('bottom-full');
expect(position.value.class).toContain('top-full');
expect(position.value.class).not.toContain('bottom-full');
});
it('should position dropdown above trigger when insufficient space below', () => {
// Trigger near bottom of screen
// Window height: 768, Trigger bottom: 750
// Window height: 768, Trigger bottom: 750, Dropdown height: 200, Margin: 16
// Space below: 768 - 750 = 18px
// Dropdown height: 200px + SAFE_MARGIN (16px) = 216px
// 18 < 216 = true, so should position above (bottom-full)
// Required: 200 + 16 = 216px
// 18 < 216, so position above
triggerRef.value.getBoundingClientRect = () => ({
top: 700,
right: 250,
@@ -165,42 +124,27 @@ describe('useDropdownPosition', () => {
height: 50,
});
const { positionClasses } = useDropdownPosition(
triggerRef,
dropdownRef,
enabled
);
const { position } = useDropdownPosition(triggerRef, dropdownRef, enabled);
expect(positionClasses.value).toContain('bottom-full');
expect(positionClasses.value).not.toContain('top-full');
expect(position.value.class).toContain('bottom-full');
expect(position.value.class).not.toContain('top-full');
});
it('should align dropdown to left by default (LTR)', () => {
// LTR layout, trigger at left: 50
// Window width: 1024, Dropdown width: 250
// Trigger left: 50, Dropdown width: 250, SAFE_MARGIN: 16
// Would overflow: 50 + 250 + 16 = 316 < 1024 = false
// So should align left (ltr:left-0)
querySelectorSpy.mockReturnValue({
getAttribute: () => 'ltr',
});
// Window width: 1024, Trigger left: 50, Dropdown width: 250
// Available right: 1024 - 50 = 974px
// Overflow: 250 - 974 = -724 (no overflow)
// Result: left: 0px
const { position } = useDropdownPosition(triggerRef, dropdownRef, enabled);
const { positionClasses } = useDropdownPosition(
triggerRef,
dropdownRef,
enabled
);
expect(positionClasses.value).toContain('ltr:left-0');
expect(positionClasses.value).not.toContain('ltr:right-0');
expect(position.value.style).toEqual({ left: '0px' });
});
it('should align dropdown to right when would overflow (LTR)', () => {
// Trigger near right edge
// Window width: 1024, Dropdown width: 250
// Trigger left: 800, Dropdown width: 250, SAFE_MARGIN: 16
// Would overflow: 800 + 250 + 16 = 1066 > 1024 = true
// So should align right (ltr:right-0)
it('should shift left when dropdown would overflow right edge (LTR)', () => {
// Window width: 1024, Trigger left: 800, Dropdown width: 250
// Available right: 1024 - 800 = 224px
// Overflow: 250 - 224 = 26px
// Result: left: -26px
triggerRef.value.getBoundingClientRect = () => ({
top: 100,
right: 1000,
@@ -210,14 +154,9 @@ describe('useDropdownPosition', () => {
height: 50,
});
const { positionClasses } = useDropdownPosition(
triggerRef,
dropdownRef,
enabled
);
const { position } = useDropdownPosition(triggerRef, dropdownRef, enabled);
expect(positionClasses.value).toContain('ltr:right-0');
expect(positionClasses.value).not.toContain('ltr:left-0');
expect(position.value.style).toEqual({ left: '-26px' });
});
it('should handle RTL layout', () => {
@@ -225,24 +164,16 @@ describe('useDropdownPosition', () => {
getAttribute: () => 'rtl',
});
const { positionClasses } = useDropdownPosition(
triggerRef,
dropdownRef,
enabled
);
const { position } = useDropdownPosition(triggerRef, dropdownRef, enabled);
expect(positionClasses.value).toContain('rtl:');
expect(position.value.style).toHaveProperty('right');
});
it('should reactively update when enabled changes', async () => {
const { positionClasses } = useDropdownPosition(
triggerRef,
dropdownRef,
enabled
);
const { position } = useDropdownPosition(triggerRef, dropdownRef, enabled);
// Initially enabled should calculate position
const initialValue = positionClasses.value;
const initialValue = position.value.class;
expect(initialValue).toBeTruthy();
expect(typeof initialValue).toBe('string');
@@ -250,14 +181,15 @@ describe('useDropdownPosition', () => {
enabled.value = false;
await nextTick();
expect(positionClasses.value).toBe('top-full mt-2 ltr:left-0 rtl:right-0');
expect(position.value.class).toBe('top-full mt-2');
expect(position.value.style).toEqual({});
// Re-enable - should calculate again
enabled.value = true;
await nextTick();
expect(positionClasses.value).toBeTruthy();
expect(typeof positionClasses.value).toBe('string');
expect(position.value.class).toBeTruthy();
expect(typeof position.value.class).toBe('string');
});
it('should provide updatePosition function', () => {
@@ -276,9 +208,9 @@ describe('useDropdownPosition', () => {
it('should handle optional enabled parameter', () => {
// Should work without enabled parameter (backward compatibility)
const { positionClasses } = useDropdownPosition(triggerRef, dropdownRef);
const { position } = useDropdownPosition(triggerRef, dropdownRef);
// Should default to enabled (not return default classes)
expect(positionClasses.value).toBeTruthy();
expect(position.value.class).toBeTruthy();
});
});
@@ -1,63 +1,82 @@
import { computed } from 'vue';
import { computed, unref, watch } from 'vue';
import { useElementBounding, useWindowSize } from '@vueuse/core';
const FALLBACK_SIZE = 200;
const RTL_DIRECTION = 'rtl';
/**
* Auto-position dropdown based on available space
* Auto-position dropdown based on available space within container
* Returns reactive position object containing class and style
*
* @param {Ref} triggerRef - Trigger element ref
* @param {Ref} dropdownRef - Dropdown element ref
* @param {Ref} enabled - Whether to calculate position
* @returns {Object} { positionClasses, updatePosition }
* @param {Object} options - { container: Ref, margin: Number }
*/
export function useDropdownPosition(triggerRef, dropdownRef, enabled) {
const SAFE_MARGIN = 16;
const RTL = 'rtl';
export function useDropdownPosition(
triggerRef,
dropdownRef,
enabled,
{ container = null, margin = 16 } = {}
) {
const trigger = useElementBounding(triggerRef);
const dropdown = useElementBounding(dropdownRef);
const bounds = useElementBounding(container);
const { width: winWidth, height: winHeight } = useWindowSize();
const triggerBounds = useElementBounding(triggerRef);
const dropdownBounds = useElementBounding(dropdownRef);
const { height: windowHeight, width: windowWidth } = useWindowSize();
const isRTL = computed(
() =>
document.querySelector('#app[dir]')?.getAttribute('dir') === RTL_DIRECTION
);
const positionClasses = computed(() => {
// Don't calculate if not enabled
if (!enabled?.value) {
return 'top-full mt-2 ltr:left-0 rtl:right-0';
const position = computed(() => {
// Default fallback if not enabled or refs aren't ready
if (!unref(enabled)) {
return { class: 'top-full mt-2', style: {} };
}
if (!triggerRef.value || !dropdownRef.value) {
return 'top-full mt-2 ltr:left-0 rtl:right-0';
}
const dropdownHeight = dropdown.height.value || FALLBACK_SIZE;
const dropdownWidth = dropdown.width.value || FALLBACK_SIZE;
const spaceBelow = windowHeight.value - triggerBounds.bottom.value;
const dropdownHeight = dropdownBounds.height.value || 200;
const dropdownWidth = dropdownBounds.width.value || 200;
// Check if RTL by checking app div with dir attribute
const appDiv = document.querySelector('#app[dir]');
const isRTL = appDiv?.getAttribute('dir') === RTL;
// Use appropriate edge based on text direction
const triggerEdge = isRTL
? triggerBounds.right.value
: triggerBounds.left.value;
const wouldOverflow = isRTL
? triggerEdge - dropdownWidth - SAFE_MARGIN < 0
: triggerEdge + dropdownWidth + SAFE_MARGIN > windowWidth.value;
const vertical =
spaceBelow >= dropdownHeight + SAFE_MARGIN
/* ---------- Vertical (Tailwind Classes) ---------- */
const spaceBelow = winHeight.value - trigger.bottom.value;
const className =
spaceBelow >= dropdownHeight + margin
? 'top-full mt-2'
: 'bottom-full mb-2';
const horizontal = wouldOverflow
? 'ltr:right-0 rtl:left-0'
: 'ltr:left-0 rtl:right-0';
/* ---------- Horizontal (Inline Styles for precise alignment) ---------- */
const leftBound = container ? bounds.left.value : 0;
const rightBound = container ? bounds.right.value : winWidth.value;
return `${vertical} ${horizontal}`;
const style = {};
if (isRTL.value) {
const availableLeft = trigger.right.value - leftBound;
const overflow = dropdownWidth - availableLeft;
style.right = overflow > 0 ? `-${overflow}px` : '0px';
} else {
const availableRight = rightBound - trigger.left.value;
const overflow = dropdownWidth - availableRight;
style.left = overflow > 0 ? `-${overflow}px` : '0px';
}
return { class: className, style };
});
const updatePosition = () => {
triggerBounds.update();
dropdownBounds.update();
trigger.update();
dropdown.update();
if (container) bounds.update();
};
return { positionClasses, updatePosition };
// Update position when dropdown opens to ensure RTL state is current
watch(
() => unref(enabled),
isEnabled => {
if (isEnabled) updatePosition();
}
);
return { position, updatePosition };
}
@@ -339,6 +339,8 @@
"SELF_ASSIGN": "Self-assign",
"TEAM_LABEL": "Team",
"PARTICIPANTS_LABEL": "Participants",
"LABELS": "Labels",
"LABELS_EMPTY_STATE": "No labels added yet",
"SELECT": {
"PLACEHOLDER": "None"
},
@@ -28,7 +28,7 @@ const dropdownRef = ref(null);
const [openAgentsList, toggleAgentsList] = useToggle(false);
const { positionClasses } = useDropdownPosition(
const { position } = useDropdownPosition(
triggerRef,
dropdownRef,
openAgentsList
@@ -231,8 +231,8 @@ const handleAgentAction = ({ action, value }) => {
:search-placeholder="
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.PLACEHOLDER.AGENT')
"
class="z-[100] w-52 overflow-y-auto max-h-60"
:class="positionClasses"
class="z-[100] w-52 overflow-y-auto max-h-60 ltr:left-0 rtl:right-0"
:class="position.class"
@action="handleAgentAction"
>
<template #icon="{ item }">
@@ -143,18 +143,18 @@ onMounted(() => {
>
<div class="flex items-center gap-1.5">
<div
class="ltr:pl-1.5 ltr:pr-2 rtl:pl-2 rtl:pr-1.5 h-6 rounded-md bg-n-button-color outline outline-1 outline-n-container flex items-center"
class="ltr:pl-1.5 ltr:pr-2 rtl:pl-2 rtl:pr-1.5 h-6 rounded-md bg-n-label-color outline outline-1 outline-n-label-border -outline-offset-1 flex items-center"
>
<InboxName
v-if="inbox?.id"
:inbox="inbox"
class="gap-1 [&>span]:text-n-slate-12"
class="gap-1 [&>span:last-child]:text-n-slate-12"
/>
</div>
<div
class="px-1.5 h-6 rounded-md bg-n-button-color outline outline-1 outline-n-container flex items-center gap-0.6"
class="px-1.5 h-6 rounded-md bg-n-label-color outline outline-1 outline-n-label-border -outline-offset-1 flex items-center gap-1"
>
<Icon icon="i-lucide-hash" class="size-3.5 text-n-slate-10" />
<Icon icon="i-woot-hash" class="size-3.5 text-n-slate-10" />
<span class="text-label-small">{{ conversationId }}</span>
</div>
</div>
@@ -167,7 +167,7 @@ onMounted(() => {
ghost-class="ghost"
handle=".drag-handle"
item-key="name"
class="flex flex-col divide-y divide-n-weak"
class="flex flex-col gap-3 my-4"
@start="dragging = true"
@end="onDragEnd"
>
@@ -31,28 +31,28 @@ const conversationUiFlags = useMapGetter('conversationLabels/getUIFlags');
const triggerRef = ref(null);
const dropdownRef = ref(null);
const wrapperRef = ref(null);
const searchQuery = ref('');
const [openLabelsList, toggleLabels] = useToggle(false);
const [createModalVisible, toggleCreateModal] = useToggle(false);
const [hasEmptySearchResults, setEmptySearchResults] = useToggle(false);
const { positionClasses, updatePosition } = useDropdownPosition(
const { position, updatePosition } = useDropdownPosition(
triggerRef,
dropdownRef,
openLabelsList
openLabelsList,
{ container: wrapperRef }
);
// Update position of dropdown when labels change
// Update position when labels change
watch(
activeLabels,
async () => {
() => activeLabels.value.length,
() => {
if (openLabelsList.value) {
await nextTick();
updatePosition();
nextTick(() => updatePosition());
}
},
{ deep: true }
}
);
const keyboardEvents = {
@@ -119,18 +119,24 @@ const hideCreateModal = () => {
</script>
<template>
<div class="flex flex-wrap gap-3 w-full items-start pt-3">
<div ref="wrapperRef" class="flex flex-wrap gap-2 w-full items-center mb-1">
<div class="h-9 flex items-center">
<span class="text-sm font-420 text-n-slate-11 truncate whitespace-nowrap">
{{ $t('CONVERSATION_SIDEBAR.LABELS') }}
</span>
</div>
<Spinner
v-if="conversationUiFlags.isFetching"
:size="22"
class="text-n-slate-10"
/>
<div v-else class="flex flex-wrap gap-2.5">
<div v-else class="flex flex-wrap gap-2">
<Label
v-for="(label, index) in activeLabels"
:key="label ? label.id : index"
data-label
:label="label"
compact
/>
<div
v-on-click-outside="() => toggleLabels(false)"
@@ -140,10 +146,10 @@ const hideCreateModal = () => {
ref="triggerRef"
:label="$t('CONTACT_PANEL.LABELS.CONVERSATION.ADD_BUTTON')"
slate
sm
icon="i-lucide-plus"
xs
icon="i-lucide-tag"
:variant="openLabelsList ? 'faded' : 'solid'"
class="font-460 !-outline-offset-1"
class="!-outline-offset-1"
@click="toggleLabels()"
/>
<DropdownMenu
@@ -155,7 +161,8 @@ const hideCreateModal = () => {
$t('CONTACT_PANEL.LABELS.LABEL_SELECT.PLACEHOLDER')
"
class="z-[100] w-56 overflow-y-auto max-h-60"
:class="positionClasses"
:class="position.class"
:style="position.style"
@action="handleLabelAction"
@search="handleSearchUpdate"
@empty="handleEmptyResults"
@@ -94,6 +94,7 @@ onMounted(() => {
ghost-class="ghost"
handle=".drag-handle"
item-key="id"
class="px-3"
@start="dragging = true"
@end="onDragEnd"
>
@@ -29,11 +29,7 @@ const showPreview = ref(false);
const triggerRef = ref(null);
const dropdownRef = ref(null);
const { positionClasses } = useDropdownPosition(
triggerRef,
dropdownRef,
showPreview
);
const { position } = useDropdownPosition(triggerRef, dropdownRef, showPreview);
const executeMacro = async macro => {
try {
@@ -62,7 +58,7 @@ const closeMacroPreview = () => {
<template>
<div
class="relative flex items-center justify-between leading-4 rounded-md h-10 ltr:pl-1.5 ltr:pr-3 rtl:pl-3 rtl:pr-1.5"
class="relative flex items-center justify-between leading-4 rounded-md h-10"
:class="showPreview ? 'cursor-default' : 'drag-handle cursor-grab'"
>
<div class="flex items-center justify-start gap-1">
@@ -96,8 +92,8 @@ const closeMacroPreview = () => {
ref="dropdownRef"
v-on-clickaway="closeMacroPreview"
:macro="macro"
class="ltr:ml-8 rtl:mr-8 !-mt-8"
:class="positionClasses"
class="ltr:ml-8 rtl:mr-8 !-mt-8 ltr:left-1 rtl:right-1"
:class="position.class"
/>
</transition>
</div>
@@ -31,7 +31,7 @@ const dropdownRef = ref(null);
const [openParticipantsList, toggleParticipantsList] = useToggle(false);
const { positionClasses } = useDropdownPosition(
const { position } = useDropdownPosition(
triggerRef,
dropdownRef,
openParticipantsList
@@ -241,8 +241,8 @@ onMounted(() => {
:search-placeholder="
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.PLACEHOLDER.AGENT')
"
class="z-[100] w-52 overflow-y-auto max-h-60"
:class="positionClasses"
class="z-[100] w-52 overflow-y-auto max-h-60 ltr:left-0 rtl:right-0"
:class="position.class"
@action="handleParticipantAction"
>
<template #trailing-icon="{ item }">
@@ -24,7 +24,7 @@ const dropdownRef = ref(null);
const [openPriorityList, togglePriorityList] = useToggle(false);
const { positionClasses } = useDropdownPosition(
const { position } = useDropdownPosition(
triggerRef,
dropdownRef,
openPriorityList
@@ -171,8 +171,8 @@ const handlePriorityAction = ({ value }) => {
:search-placeholder="
$t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.INPUT_PLACEHOLDER')
"
class="z-[100] w-48 overflow-y-auto max-h-60"
:class="positionClasses"
class="z-[100] w-48 overflow-y-auto max-h-60 ltr:left-0 rtl:right-0"
:class="position.class"
@action="handlePriorityAction"
>
<template #icon="{ item }">
@@ -28,7 +28,7 @@ const dropdownRef = ref(null);
const [openTeamsList, toggleTeamsList] = useToggle(false);
const { positionClasses } = useDropdownPosition(
const { position } = useDropdownPosition(
triggerRef,
dropdownRef,
openTeamsList
@@ -173,8 +173,8 @@ const handleTeamAction = ({ action, value }) => {
:search-placeholder="
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.PLACEHOLDER.TEAM')
"
class="z-[100] w-52 overflow-y-auto max-h-60"
:class="positionClasses"
class="z-[100] w-52 overflow-y-auto max-h-60 ltr:left-0 rtl:right-0"
:class="position.class"
@action="handleTeamAction"
>
<template #trailing-icon="{ item }">
@@ -98,18 +98,7 @@ watch(
</script>
<template>
<div>
<div v-if="notes.length" class="px-3 pt-3 pb-2">
<NextButton
sm
slate
icon="i-lucide-plus"
:label="$t('CONTACTS_LAYOUT.SIDEBAR.NOTES.ADD_NOTE')"
:disabled="!contactId || isFetchingNotes"
@click="openCreateModal"
/>
</div>
<div class="mt-1">
<div
v-if="isFetchingNotes"
class="flex items-center justify-center py-8 text-n-slate-11"
@@ -146,6 +135,18 @@ watch(
/>
</div>
<div v-if="notes.length" class="pt-3 px-3 w-full">
<NextButton
sm
slate
icon="i-lucide-plus"
:label="$t('CONTACTS_LAYOUT.SIDEBAR.NOTES.ADD_NOTE')"
:disabled="!contactId || isFetchingNotes"
class="w-full"
@click="openCreateModal"
/>
</div>
<woot-modal
v-model:show="shouldShowCreateModal"
:on-close="closeCreateModal"