feat: allow control for expand and collapse

This commit is contained in:
Shivam Mishra
2026-06-10 16:15:59 +05:30
parent e3133e2103
commit 71fc0e0ab1
4 changed files with 97 additions and 34 deletions
+38 -32
View File
@@ -25,6 +25,7 @@ const ARTICLE_VIEW_RESIZE_DURATION = 180;
import { useDarkMode } from 'widget/composables/useDarkMode';
import { useRouter } from 'vue-router';
import { useAvailability } from 'widget/composables/useAvailability';
import { useArticleView } from 'widget/composables/useArticleView';
import { SDK_SET_BUBBLE_VISIBILITY } from '../shared/constants/sharedFrameEvents';
import { emitter } from 'shared/helpers/mitt';
import {
@@ -42,14 +43,22 @@ export default {
const { prefersDarkMode } = useDarkMode();
const router = useRouter();
const { isInWorkingHours } = useAvailability();
const { isArticleView, isWidgetExpanded, setArticleView } =
useArticleView();
return { prefersDarkMode, router, isInWorkingHours };
return {
prefersDarkMode,
router,
isInWorkingHours,
isArticleView,
isWidgetExpanded,
setArticleView,
};
},
data() {
return {
isMobile: false,
campaignsSnoozedTill: undefined,
isArticleView: false,
};
},
computed: {
@@ -76,6 +85,11 @@ export default {
? getLanguageDirection(this.$root.$i18n.locale)
: false;
},
shouldExpandArticleView() {
// The widget only widens on article pages, and only when the user has
// opted in via the header toggle (persisted, collapsed by default).
return this.isArticleView && this.isWidgetExpanded;
},
},
watch: {
activeCampaign() {
@@ -88,15 +102,25 @@ export default {
},
},
'$route.name'(routeName, previousRouteName) {
// Expansion is driven by the rendered portal page (see the
// 'portalPageLoaded' handler) so the widget only widens for article pages.
// Here we just collapse when leaving the article view, since the iframe is
// torn down then and can no longer signal.
if (this.isIFrame && previousRouteName === 'article-viewer') {
// Leaving the article view tears down the iframe, so reset the flag; the
// watcher below collapses the widget if it was expanded.
if (previousRouteName === 'article-viewer') {
this.isArticleView = false;
IFrameHelper.sendMessage({ event: 'collapseWidget' });
}
},
shouldExpandArticleView(shouldExpand) {
if (!this.isIFrame) return;
// Resize the host widget and mask the iframe while it reflows off-screen,
// revealing it once the size transition settles.
IFrameHelper.sendMessage({
event: shouldExpand ? 'expandWidget' : 'collapseWidget',
});
emitter.emit(ON_ARTICLE_VIEW_RESIZING, true);
setTimeout(
() => emitter.emit(ON_ARTICLE_VIEW_RESIZING, false),
ARTICLE_VIEW_RESIZE_DURATION
);
},
},
mounted() {
const { websiteToken, locale, widgetColor } = window.chatwootWebChannel;
@@ -200,29 +224,6 @@ export default {
query: { link, v: Date.now() },
});
},
setArticleView(isArticle) {
// portalPageLoaded is delivered asynchronously and can arrive after we've
// already left the article view; ignore it unless we're still on that
// route so the expanded width can't leak onto the listing or other views.
if (!this.isIFrame || this.$route.name !== 'article-viewer') return;
// Re-assert the width on every page load (the SDK toggles are idempotent)
// so the holder always reflects the current page, then only mask the
// reflow when the width actually changes to avoid a needless flash.
const didChange = isArticle !== this.isArticleView;
this.isArticleView = isArticle;
IFrameHelper.sendMessage({
event: isArticle ? 'expandWidget' : 'collapseWidget',
});
if (didChange) {
emitter.emit(ON_ARTICLE_VIEW_RESIZING, true);
setTimeout(
() => emitter.emit(ON_ARTICLE_VIEW_RESIZING, false),
ARTICLE_VIEW_RESIZE_DURATION
);
}
},
registerUnreadEvents() {
emitter.on(ON_AGENT_MESSAGE_RECEIVED, () => {
const { name: routeName } = this.$route;
@@ -382,7 +383,12 @@ export default {
} else if (message.event === 'open-article') {
this.openArticle(message.slug);
} else if (message.event === 'portalPageLoaded') {
this.setArticleView(message.isArticle);
// portalPageLoaded is delivered asynchronously and can arrive after
// we've left the article view; ignore it unless we're still there so
// the expanded width can't leak onto the listing or other views.
if (this.$route.name === 'article-viewer') {
this.setArticleView(message.isArticle);
}
} else if (message.event === 'toggle-open') {
this.$store.dispatch('appConfig/toggleWidgetOpen', message.isOpen);
@@ -1,10 +1,12 @@
<script setup>
import { toRef } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import HeaderActions from './HeaderActions.vue';
import AvailabilityContainer from 'widget/components/Availability/AvailabilityContainer.vue';
import { useAvailability } from 'widget/composables/useAvailability';
import { useArticleView } from 'widget/composables/useArticleView';
const props = defineProps({
avatarUrl: { type: String, default: '' },
@@ -17,7 +19,10 @@ const props = defineProps({
const availableAgents = toRef(props, 'availableAgents');
const router = useRouter();
const { t } = useI18n();
const { isOnline } = useAvailability(availableAgents);
const { isArticleView, isWidgetExpanded, toggleWidgetExpanded } =
useArticleView();
const onBackButtonClick = () => {
router.replace({ name: 'home' });
@@ -58,6 +63,25 @@ const onBackButtonClick = () => {
/>
</div>
</div>
<HeaderActions :show-popout-button="showPopoutButton" />
<div class="flex items-center gap-3">
<button
v-if="isArticleView"
class="button transparent compact"
:title="
isWidgetExpanded
? t('PORTAL.COLLAPSE_ARTICLE')
: t('PORTAL.EXPAND_ARTICLE')
"
@click="toggleWidgetExpanded"
>
<span
class="size-4 text-n-slate-12"
:class="
isWidgetExpanded ? 'i-lucide-minimize-2' : 'i-lucide-maximize-2'
"
/>
</button>
<HeaderActions :show-popout-button="showPopoutButton" />
</div>
</header>
</template>
@@ -0,0 +1,31 @@
import { ref } from 'vue';
import { LocalStorage } from 'shared/helpers/localStorage';
const EXPANDED_STORAGE_KEY = 'chatwoot:widget:articleViewExpanded';
// Module-level singletons so the header toggle and the resize logic in App.vue
// share a single source of truth.
//
// `isArticleView` - whether the iframe is currently showing an article page.
// `isWidgetExpanded`- the user's persisted expand/collapse preference. Defaults
// to collapsed and only ever applies on article pages.
const isArticleView = ref(false);
const isWidgetExpanded = ref(LocalStorage.get(EXPANDED_STORAGE_KEY) === true);
export function useArticleView() {
const setArticleView = value => {
isArticleView.value = value;
};
const toggleWidgetExpanded = () => {
isWidgetExpanded.value = !isWidgetExpanded.value;
LocalStorage.set(EXPANDED_STORAGE_KEY, isWidgetExpanded.value);
};
return {
isArticleView,
isWidgetExpanded,
setArticleView,
toggleWidgetExpanded,
};
}
+3 -1
View File
@@ -125,7 +125,9 @@
"PORTAL": {
"POPULAR_ARTICLES": "Popular Articles",
"VIEW_ALL_ARTICLES": "View all articles",
"IFRAME_LOAD_ERROR": "There was an error loading the article, please refresh the page and try again."
"IFRAME_LOAD_ERROR": "There was an error loading the article, please refresh the page and try again.",
"EXPAND_ARTICLE": "Expand",
"COLLAPSE_ARTICLE": "Collapse"
},
"ATTACHMENTS": {
"image": {