chore: Update theme colors and add new Inter variable fonts (#13347)

# Pull Request Template

## Description

This PR includes the following updates:

1. Updated the design system color tokens by introducing new tokens for
surfaces, overlays, buttons, labels, and cards, along with refinements
to existing shades.
2. Refreshed both light and dark themes with adjusted background,
border, and solid colors.
3. Replaced static Inter font files with the Inter variable font
(including italic), supporting weights from 100–900.
4. Added custom font weights (420, 440, 460, 520) along with custom
typography classes to enable more fine-grained and consistent typography
control.


## Type of change

- [x] New feature (non-breaking change which adds functionality)


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
Sivin Varghese
2026-01-28 14:36:04 -08:00
committed by GitHub
co-authored by Pranav
parent 0ca98bc84f
commit 2a69b37958
101 changed files with 927 additions and 265 deletions
@@ -119,7 +119,7 @@ onMounted(() => {
)
"
:items="filteredTags"
class="[&>button]:!text-n-blue-text [&>div]:min-w-64"
class="[&>button]:!text-n-blue-11 [&>div]:min-w-64"
@add="onClickAddTag"
/>
</div>
@@ -20,7 +20,7 @@ const handleButtonClick = () => {
</script>
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header class="sticky top-0 z-10 px-6 lg:px-0">
<div class="w-full max-w-[60rem] mx-auto">
<div class="flex items-center justify-between w-full h-20 gap-2">
@@ -306,7 +306,7 @@ defineExpose({ prepareCampaignDetails, isSubmitDisabled });
variant="faded"
color="slate"
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -174,7 +174,7 @@ const handleSubmit = async () => {
color="slate"
type="button"
:label="t('CAMPAIGN.SMS.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -251,7 +251,7 @@ watch(
color="slate"
type="button"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -21,7 +21,7 @@ const updateCurrentPage = page => {
<template>
<section
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-surface-1"
>
<div class="flex flex-col w-full h-full transition-all duration-300">
<CompanyHeader
@@ -73,7 +73,7 @@ const closeMobileSidebar = () => {
<template>
<section
class="flex w-full h-full overflow-hidden justify-evenly bg-n-background"
class="flex w-full h-full overflow-hidden justify-evenly bg-n-surface-1"
>
<div
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
@@ -73,7 +73,7 @@ defineExpose({ dialogRef });
target="_blank"
rel="noopener noreferrer"
download="import-contacts-sample.csv"
class="text-n-blue-text"
class="text-n-blue-11"
>
{{
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.DOWNLOAD_LABEL')
@@ -78,7 +78,7 @@ const showPagination = computed(() => {
<template>
<section
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-surface-1"
>
<div class="flex flex-col w-full h-full transition-all duration-300">
<ContactListHeaderWrapper
@@ -130,7 +130,7 @@ const onMergeContacts = async () => {
variant="faded"
color="slate"
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="resetState"
/>
<Button
@@ -128,7 +128,7 @@ const handleInputUpdate = async () => {
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate': isEditingView && !isAttributeTypeLink,
'truncate hover:text-n-brand text-n-blue-text':
'truncate hover:text-n-brand text-n-blue-11':
isEditingView && isAttributeTypeLink,
}"
@click="toggleEditValue(!isEditingView)"
@@ -37,7 +37,7 @@ defineProps({
<div
class="flex flex-col items-center justify-end w-full h-full pb-20"
:class="{
'absolute inset-x-0 bottom-0 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent':
'absolute inset-x-0 bottom-0 bg-gradient-to-t from-n-surface-1 from-25% to-transparent':
showBackdrop,
}"
>
@@ -48,14 +48,12 @@ defineProps({
}"
>
<div class="flex flex-col items-center justify-center gap-3">
<h2
class="text-3xl font-medium text-center text-n-slate-12 font-interDisplay"
>
<h2 class="text-3xl font-medium text-center text-n-slate-12">
{{ title }}
</h2>
<p
v-if="subtitle"
class="max-w-xl text-base text-center text-n-slate-11 font-interDisplay tracking-[0.3px]"
class="max-w-xl text-base text-center text-n-slate-11 tracking-[0.3px]"
>
{{ subtitle }}
</p>
@@ -126,7 +126,7 @@ const handleClick = id => {
<CardLayout>
<div class="flex justify-between w-full gap-1">
<span
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12 line-clamp-1"
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12 line-clamp-1"
@click="handleClick(id)"
>
{{ title }}
@@ -83,7 +83,7 @@ const handleAction = ({ action, value }) => {
<div class="flex justify-between w-full gap-2">
<div class="flex items-center justify-start w-full min-w-0 gap-2">
<span
class="text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12"
class="text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12"
@click="handleClick(slug)"
>
{{ categoryTitleWithIcon }}
@@ -58,7 +58,7 @@ const togglePortalSwitcher = () => {
</script>
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
<div class="w-full max-w-[60rem] mx-auto lg:px-6">
<div
@@ -60,7 +60,7 @@ const handleAction = ({ action, value }) => {
</span>
<span
v-if="isDefault"
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-blue-text px-2 py-0.5"
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-blue-11 px-2 py-0.5"
>
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
</span>
@@ -246,7 +246,7 @@ defineExpose({ state, isSubmitDisabled });
variant="faded"
color="slate"
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -100,7 +100,7 @@ const formattedMessage = computed(() => {
const notificationDetails = computed(() => {
const type = props.inboxItem?.notificationType?.toUpperCase() || '';
const [icon = '', color = 'text-n-blue-text'] =
const [icon = '', color = 'text-n-blue-11'] =
NOTIFICATION_TYPES_MAPPING[type] || [];
return { text: type ? t(`INBOX.TYPES_NEXT.${type}`) : '', icon, color };
});
@@ -181,11 +181,11 @@ onBeforeMount(contextMenuActions.close);
: 'i-lucide-alarm-clock-off'
"
class="flex-shrink-0 size-4"
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-text'"
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-11'"
/>
<span
class="text-xs font-medium truncate"
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-text'"
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-11'"
>
{{ snoozedText }}
</span>
@@ -103,11 +103,11 @@ const STYLE_CONFIG = {
solid:
'bg-n-brand text-white hover:enabled:brightness-110 focus-visible:brightness-110 outline-transparent',
faded:
'bg-n-brand/10 text-n-blue-text hover:enabled:bg-n-brand/20 focus-visible:bg-n-brand/20 outline-transparent',
outline: 'text-n-blue-text outline-n-brand',
'bg-n-brand/10 text-n-blue-11 hover:enabled:bg-n-brand/20 focus-visible:bg-n-brand/20 outline-transparent',
outline: 'text-n-blue-11 outline-n-brand',
ghost:
'text-n-blue-text hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
link: 'text-n-blue-text hover:enabled:underline focus-visible:underline outline-transparent',
'text-n-blue-11 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
link: 'text-n-blue-11 hover:enabled:underline focus-visible:underline outline-transparent',
},
ruby: {
solid:
@@ -133,7 +133,7 @@ const STYLE_CONFIG = {
},
slate: {
solid:
'bg-n-solid-3 dark:hover:enabled:bg-n-solid-2 dark:focus-visible:bg-n-solid-2 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 text-n-slate-12 outline-n-container',
'bg-n-button-color dark:hover:enabled:bg-n-solid-2 dark:focus-visible:bg-n-solid-2 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 text-n-slate-12 outline-n-container',
faded:
'bg-n-slate-9/10 text-n-slate-12 hover:enabled:bg-n-slate-9/20 focus-visible:bg-n-slate-9/20 outline-transparent',
outline:
@@ -115,7 +115,7 @@ const handleCreateAssistant = () => {
</script>
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header class="sticky top-0 z-10 px-6">
<div class="w-full max-w-[60rem] mx-auto">
<div
@@ -141,7 +141,7 @@ const onClickCancel = () => {
variant="faded"
color="slate"
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.CANCEL')"
class="w-full bg-n-alpha-2 !text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 !text-n-blue-11 hover:bg-n-alpha-3"
@click="onClickCancel"
/>
<Button
@@ -169,7 +169,7 @@ watch(
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -254,7 +254,7 @@ const handleSubmit = async () => {
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -220,7 +220,7 @@ const handleSubmit = async () => {
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -100,7 +100,7 @@ const handleSubmit = async () => {
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -116,7 +116,7 @@ watch(
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -34,7 +34,7 @@ const handleImgClick = () => {
<template>
<div
data-testid="changelog-card"
class="flex flex-col justify-between p-3 w-full rounded-lg border shadow-sm transition-all duration-200 border-n-weak bg-n-background text-n-slate-12"
class="flex flex-col justify-between p-3 w-full rounded-lg border shadow-sm transition-all duration-200 border-n-weak bg-n-card text-n-slate-12"
:class="{
'animate-fade-out pointer-events-none': isDismissing,
'hover:shadow': isActive,
@@ -77,12 +77,12 @@ const queryOperatorOptions = computed(() => {
{
label: t(`FILTER.QUERY_DROPDOWN_LABELS.AND`),
value: 'and',
icon: h('span', { class: 'i-lucide-ampersands !text-n-blue-text' }),
icon: h('span', { class: 'i-lucide-ampersands !text-n-blue-11' }),
},
{
label: t(`FILTER.QUERY_DROPDOWN_LABELS.OR`),
value: 'or',
icon: h('span', { class: 'i-woot-logic-or !text-n-blue-text' }),
icon: h('span', { class: 'i-woot-logic-or !text-n-blue-11' }),
},
];
});
@@ -82,7 +82,7 @@ export function useOperators() {
hasInput: !NO_INPUT_OPTS.includes(value),
inputOverride: OPS_INPUT_OVERRIDE[value] || null,
icon: h('span', {
class: `${filterOperatorIcon[value]} !text-n-blue-text`,
class: `${filterOperatorIcon[value]} !text-n-blue-11`,
}),
};
return acc;
@@ -163,7 +163,7 @@ const getInReplyToMessage = parentMessage => {
</script>
<template>
<ul class="px-4 bg-n-background">
<ul class="px-4 bg-n-surface-1">
<slot name="beforeAll" />
<template v-for="(message, index) in allMessages" :key="message.id">
<slot
@@ -22,7 +22,7 @@ defineProps({
/>
</div>
<div class="flex gap-2">
<Button :label="buttonText" slate class="!text-n-blue-text w-full" />
<Button :label="buttonText" slate class="!text-n-blue-11 w-full" />
</div>
</div>
</template>
@@ -18,12 +18,8 @@ defineProps({
/>
</div>
<div class="flex gap-2">
<Button label="Call us" slate class="!text-n-blue-text w-full" />
<Button
label="Visit our website"
slate
class="!text-n-blue-text w-full"
/>
<Button label="Call us" slate class="!text-n-blue-11 w-full" />
<Button label="Visit our website" slate class="!text-n-blue-11 w-full" />
</div>
</div>
</template>
@@ -71,7 +71,7 @@ const pageInfo = computed(() => {
<template>
<div
class="flex justify-between h-12 w-full max-w-[calc(60rem-3px)] outline outline-n-container outline-1 -outline-offset-1 mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-background before:from-10% before:dark:from-0% before:to-transparent before:h-4 before:pointer-events-none"
class="flex justify-between h-12 w-full max-w-[calc(60rem-3px)] outline outline-n-container outline-1 -outline-offset-1 mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-surface-1 before:from-10% before:dark:from-0% before:to-transparent before:h-4 before:pointer-events-none"
>
<div class="flex items-center gap-3">
<span class="min-w-0 text-sm font-normal line-clamp-1 text-n-slate-11">
@@ -8,6 +8,7 @@ const props = defineProps({
type: String,
required: true,
},
// eslint-disable-next-line vue/no-unused-properties
active: {
type: Boolean,
default: false,
@@ -24,10 +25,7 @@ const reauthorizationRequired = computed(() => {
</script>
<template>
<span
class="size-5 grid place-content-center rounded-full bg-n-alpha-2"
:class="{ 'bg-n-solid-blue': active }"
>
<span class="size-5 grid place-content-center rounded-full bg-n-alpha-2">
<ChannelIcon :inbox="inbox" class="size-3" />
</span>
<div class="flex-1 truncate min-w-0">{{ label }}</div>
@@ -39,7 +39,7 @@ const toggleSidebar = () => {
<div
v-if="!isConversationRoute"
id="mobile-sidebar-launcher"
class="fixed bottom-4 ltr:left-4 rtl:right-4 z-40 transition-transform duration-200 ease-in-out block md:hidden"
class="fixed bottom-4 ltr:left-4 rtl:right-4 z-40 transition-transform duration-200 ease-out block md:hidden"
:class="[
{
'ltr:translate-x-48 rtl:-translate-x-48': isMobileSidebarOpen,
@@ -1,6 +1,6 @@
<script setup>
import { h, ref, computed, onMounted } from 'vue';
import { provideSidebarContext } from './provider';
import { provideSidebarContext, useSidebarResize } from './provider';
import { useAccount } from 'dashboard/composables/useAccount';
import { useKbd } from 'dashboard/composables/utils/useKbd';
import { useMapGetter } from 'dashboard/composables/store';
@@ -8,6 +8,7 @@ import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { vOnClickOutside } from '@vueuse/components';
import { useWindowSize, useEventListener } from '@vueuse/core';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
@@ -15,7 +16,9 @@ import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
import SidebarProfileMenu from './SidebarProfileMenu.vue';
import SidebarChangelogCard from './SidebarChangelogCard.vue';
import SidebarChangelogButton from './SidebarChangelogButton.vue';
import ChannelLeaf from './ChannelLeaf.vue';
import ChannelIcon from 'next/icon/ChannelIcon.vue';
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
@@ -42,6 +45,10 @@ const { t } = useI18n();
const isACustomBrandedInstance = useMapGetter(
'globalConfig/isACustomBrandedInstance'
);
const isRTL = useMapGetter('accounts/isRTL');
const { width: windowWidth } = useWindowSize();
const isMobile = computed(() => windowWidth.value < 768);
const toggleShortcutModalFn = show => {
if (show) {
@@ -58,11 +65,85 @@ const expandedItem = ref(null);
const setExpandedItem = name => {
expandedItem.value = expandedItem.value === name ? null : name;
};
const {
sidebarWidth,
isCollapsed,
setSidebarWidth,
saveWidth,
snapToCollapsed,
snapToExpanded,
COLLAPSED_THRESHOLD,
} = useSidebarResize();
// On mobile, sidebar is always expanded (flyout mode)
const isEffectivelyCollapsed = computed(
() => !isMobile.value && isCollapsed.value
);
// Resize handle logic
const isResizing = ref(false);
const startX = ref(0);
const startWidth = ref(0);
provideSidebarContext({
expandedItem,
setExpandedItem,
isCollapsed: isEffectivelyCollapsed,
sidebarWidth,
isResizing,
});
// Get clientX from mouse or touch event
const getClientX = event =>
event.touches ? event.touches[0].clientX : event.clientX;
const onResizeStart = event => {
isResizing.value = true;
startX.value = getClientX(event);
startWidth.value = sidebarWidth.value;
Object.assign(document.body.style, {
cursor: 'col-resize',
userSelect: 'none',
});
// Prevent default to avoid scrolling on touch
event.preventDefault();
};
const onResizeMove = event => {
if (!isResizing.value) return;
const delta = isRTL.value
? startX.value - getClientX(event)
: getClientX(event) - startX.value;
setSidebarWidth(startWidth.value + delta);
};
const onResizeEnd = () => {
if (!isResizing.value) return;
isResizing.value = false;
Object.assign(document.body.style, { cursor: '', userSelect: '' });
// Snap to collapsed state if below threshold
if (sidebarWidth.value < COLLAPSED_THRESHOLD) {
snapToCollapsed();
} else {
saveWidth();
}
};
const onResizeHandleDoubleClick = () => {
if (isCollapsed.value) snapToExpanded();
else snapToCollapsed();
};
// Support both mouse and touch events
useEventListener(document, 'mousemove', onResizeMove);
useEventListener(document, 'mouseup', onResizeEnd);
useEventListener(document, 'touchmove', onResizeMove, { passive: false });
useEventListener(document, 'touchend', onResizeEnd);
const inboxes = useMapGetter('inboxes/getInboxes');
const labels = useMapGetter('labels/getLabelsOnSidebar');
const teams = useMapGetter('teams/getMyTeams');
@@ -192,6 +273,7 @@ const menuItems = computed(() => {
children: sortedInboxes.value.map(inbox => ({
name: `${inbox.name}-${inbox.id}`,
label: inbox.name,
icon: h(ChannelIcon, { inbox, class: 'size-[12px]' }),
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
component: leafProps =>
h(ChannelLeaf, {
@@ -210,7 +292,7 @@ const menuItems = computed(() => {
name: `${label.title}-${label.id}`,
label: label.title,
icon: h('span', {
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
class: `size-[8px] rounded-sm`,
style: { backgroundColor: label.color },
}),
to: accountScopedRoute('label_conversations', {
@@ -338,7 +420,7 @@ const menuItems = computed(() => {
name: `${label.title}-${label.id}`,
label: label.title,
icon: h('span', {
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
class: `size-[8px] rounded-sm`,
style: { backgroundColor: label.color },
}),
to: accountScopedRoute(
@@ -604,32 +686,56 @@ const menuItems = computed(() => {
closeMobileSidebar,
{ ignore: ['#mobile-sidebar-launcher'] },
]"
class="bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak flex flex-col text-sm pb-1 fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 transition-transform duration-200 ease-in-out md:static w-[200px] basis-[200px] md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:-translate-x-0"
class="bg-n-background flex flex-col text-sm pb-0.5 fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 w-[200px] md:w-auto md:relative md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:translate-x-0 ltr:border-r rtl:border-l border-n-weak"
:class="[
{
'shadow-lg md:shadow-none': isMobileSidebarOpen,
'ltr:-translate-x-full rtl:translate-x-full': !isMobileSidebarOpen,
'transition-transform duration-200 ease-out md:transition-[width]':
!isResizing,
},
]"
:style="isMobile ? undefined : { width: `${sidebarWidth}px` }"
>
<section class="grid gap-2 mt-2 mb-4">
<div class="flex gap-2 items-center px-2 min-w-0">
<div class="grid flex-shrink-0 place-content-center size-6">
<Logo class="size-4" />
</div>
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
<SidebarAccountSwitcher
class="flex-grow -mx-1 min-w-0"
@show-create-account-modal="emit('showCreateAccountModal')"
/>
<section
class="grid"
:class="isEffectivelyCollapsed ? 'mt-3 mb-6 gap-4' : 'mt-1 mb-4 gap-2'"
>
<div
class="flex gap-2 items-center min-w-0"
:class="{
'justify-center px-1': isEffectivelyCollapsed,
'px-2': !isEffectivelyCollapsed,
}"
>
<template v-if="isEffectivelyCollapsed">
<SidebarAccountSwitcher
is-collapsed
@show-create-account-modal="emit('showCreateAccountModal')"
/>
</template>
<template v-else>
<div class="grid flex-shrink-0 place-content-center size-6">
<Logo class="size-4" />
</div>
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
<SidebarAccountSwitcher
class="flex-grow -mx-1 min-w-0"
@show-create-account-modal="emit('showCreateAccountModal')"
/>
</template>
</div>
<div class="flex gap-2 px-2">
<div
class="flex gap-2"
:class="isEffectivelyCollapsed ? 'flex-col items-center' : 'px-2'"
>
<RouterLink
v-if="!isEffectivelyCollapsed"
:to="{ name: 'search' }"
class="flex gap-2 items-center px-2 py-1 w-full h-7 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3 dark:bg-n-black/30"
class="flex gap-2 items-center px-2 py-1 w-full h-7 rounded-lg outline outline-1 outline-n-weak bg-n-button-color transition-all duration-100 ease-out"
>
<span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-11" />
<span class="flex-grow text-left">
<span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-10" />
<span class="flex-grow text-start text-n-slate-10">
{{ t('COMBOBOX.SEARCH_PLACEHOLDER') }}
</span>
<span
@@ -638,21 +744,41 @@ const menuItems = computed(() => {
{{ searchShortcut }}
</span>
</RouterLink>
<RouterLink
v-else
:to="{ name: 'search' }"
class="flex items-center justify-center size-8 rounded-lg outline outline-1 outline-n-weak bg-n-button-color transition-all duration-100 ease-out hover:bg-n-alpha-2 dark:hover:bg-n-slate-9/30"
:title="t('COMBOBOX.SEARCH_PLACEHOLDER')"
>
<span class="i-lucide-search size-4 text-n-slate-11" />
</RouterLink>
<ComposeConversation align-position="right" @close="onComposeClose">
<template #trigger="{ toggle }">
<template #trigger="{ toggle, isOpen }">
<Button
icon="i-lucide-pen-line"
color="slate"
size="sm"
class="!h-7 !bg-n-solid-3 dark:!bg-n-black/30 !outline-n-weak !text-n-slate-11"
class="dark:hover:!bg-n-slate-9/30"
:class="[
isEffectivelyCollapsed
? '!size-8 !outline-n-weak !text-n-slate-11'
: '!h-7 !outline-n-weak !text-n-slate-11',
{ '!bg-n-alpha-2 dark:!bg-n-slate-9/30': isOpen },
]"
@click="onComposeOpen(toggle)"
/>
</template>
</ComposeConversation>
</div>
</section>
<nav class="grid overflow-y-scroll flex-grow gap-2 px-2 pb-5 no-scrollbar">
<ul class="flex flex-col gap-1.5 m-0 list-none">
<nav
class="grid overflow-y-scroll flex-grow gap-2 pb-5 no-scrollbar min-w-0"
:class="isEffectivelyCollapsed ? 'px-1' : 'px-2'"
>
<ul
class="flex flex-col gap-1 m-0 list-none min-w-0"
:class="{ 'items-center': isEffectivelyCollapsed }"
>
<SidebarGroup
v-for="item in menuItems"
:key="item.name"
@@ -664,18 +790,43 @@ const menuItems = computed(() => {
class="flex relative flex-col flex-shrink-0 gap-1 justify-between items-center"
>
<div
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
class="pointer-events-none absolute inset-x-0 -top-[1.938rem] h-8 bg-gradient-to-t from-n-background to-transparent"
/>
<SidebarChangelogCard
v-if="isOnChatwootCloud && !isACustomBrandedInstance"
v-if="
isOnChatwootCloud &&
!isACustomBrandedInstance &&
!isEffectivelyCollapsed
"
/>
<SidebarChangelogButton
v-if="
isOnChatwootCloud &&
!isACustomBrandedInstance &&
isEffectivelyCollapsed
"
/>
<div
class="p-1 flex-shrink-0 flex w-full justify-between z-10 gap-2 items-center border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)]"
class="p-1 flex-shrink-0 flex w-full z-50 gap-2 items-center border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)]"
:class="isEffectivelyCollapsed ? 'justify-center' : 'justify-between'"
>
<SidebarProfileMenu
:is-collapsed="isEffectivelyCollapsed"
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
/>
</div>
</section>
<!-- Resize Handle (desktop only) -->
<div
class="hidden md:block absolute top-0 h-full w-1 cursor-col-resize z-40 ltr:right-0 rtl:left-0 group"
@mousedown="onResizeStart"
@touchstart="onResizeStart"
@dblclick="onResizeHandleDoubleClick"
>
<div
class="absolute top-0 h-full w-px ltr:right-0 rtl:left-0 bg-transparent group-hover:bg-n-brand transition-colors"
:class="{ 'bg-n-brand': isResizing }"
/>
</div>
</aside>
</template>
@@ -5,6 +5,7 @@ import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import ButtonNext from 'next/button/Button.vue';
import Icon from 'next/icon/Icon.vue';
import Logo from 'next/icon/Logo.vue';
import {
DropdownContainer,
@@ -13,6 +14,13 @@ import {
DropdownItem,
} from 'next/dropdown-menu/base';
defineProps({
isCollapsed: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['showCreateAccountModal']);
const { t } = useI18n();
@@ -45,7 +53,19 @@ const emitNewAccount = () => {
<template>
<DropdownContainer>
<template #trigger="{ toggle, isOpen }">
<!-- Collapsed view: Logo trigger -->
<button
v-if="isCollapsed"
class="grid flex-shrink-0 place-content-center p-2 rounded-lg cursor-pointer hover:bg-n-alpha-1"
:class="{ 'bg-n-alpha-1': isOpen }"
:title="currentAccount.name"
@click="toggle"
>
<Logo class="size-7" />
</button>
<!-- Expanded view: Account name trigger -->
<button
v-else
id="sidebar-account-switcher"
:data-account-id="accountId"
aria-haspopup="listbox"
@@ -73,7 +93,10 @@ const emitNewAccount = () => {
/>
</button>
</template>
<DropdownBody v-if="showAccountSwitcher" class="min-w-80 z-50">
<DropdownBody
v-if="showAccountSwitcher || isCollapsed"
class="min-w-80 z-50"
>
<DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_ACCOUNT')">
<DropdownItem
v-for="account in sortedCurrentUserAccounts"
@@ -0,0 +1,46 @@
<script setup>
import { computed, useTemplateRef } from 'vue';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import SidebarChangelogCard from './SidebarChangelogCard.vue';
const [isOpen, toggleOpen] = useToggle(false);
const changelogCard = useTemplateRef('changelogCard');
const isLoading = computed(() => changelogCard.value?.isLoading || false);
const hasArticles = computed(
() => changelogCard.value?.unDismissedPosts?.length > 0
);
const shouldShowButton = computed(() => !isLoading.value && hasArticles.value);
const closePopover = () => {
if (isOpen.value) {
toggleOpen(false);
}
};
</script>
<template>
<div v-on-click-outside="closePopover" class="relative mb-2">
<Button
v-if="shouldShowButton"
icon="i-lucide-sparkles"
ghost
slate
:class="{ '!bg-n-alpha-2 dark:!bg-n-slate-9/30': isOpen }"
@click="toggleOpen()"
/>
<!-- Always render card so it can fetch data, control visibility with v-show -->
<div
v-show="isOpen && hasArticles"
class="absolute ltr:left-full rtl:right-full bottom-0 ltr:ml-4 rtl:mr-4 z-40 bg-transparent w-52"
>
<SidebarChangelogCard
ref="changelogCard"
class="[&>div]:!pb-0 [&>div]:!px-0 rounded-lg"
/>
</div>
</div>
</template>
@@ -4,6 +4,10 @@ import GroupedStackedChangelogCard from 'dashboard/components-next/changelog-car
import { useUISettings } from 'dashboard/composables/useUISettings';
import changelogAPI from 'dashboard/api/changelog';
defineOptions({
inheritAttrs: false,
});
const MAX_DISMISSED_SLUGS = 5;
const { uiSettings, updateUISettings } = useUISettings();
@@ -90,6 +94,11 @@ const handleImgClick = ({ index }) => {
handleReadMore();
};
defineExpose({
isLoading,
unDismissedPosts,
});
onMounted(() => {
fetchChangelog();
});
@@ -98,6 +107,7 @@ onMounted(() => {
<template>
<GroupedStackedChangelogCard
v-if="unDismissedPosts.length > 0"
v-bind="$attrs"
:posts="unDismissedPosts"
:current-index="currentIndex"
:dismissing-slugs="dismissingCards"
@@ -0,0 +1,198 @@
<script setup>
import { computed, ref, onMounted, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { useSidebarContext } from './provider';
import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'next/icon/Icon.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
const props = defineProps({
label: { type: String, required: true },
children: { type: Array, default: () => [] },
activeChild: { type: Object, default: undefined },
triggerRect: { type: Object, default: () => ({ top: 0, left: 0 }) },
});
const emit = defineEmits(['close', 'mouseenter', 'mouseleave']);
const router = useRouter();
const { isAllowed, sidebarWidth } = useSidebarContext();
const expandedSubGroup = ref(null);
const popoverRef = ref(null);
const topPosition = ref(0);
const isRTL = useMapGetter('accounts/isRTL');
const skipTransition = ref(true);
const toggleSubGroup = name => {
expandedSubGroup.value = expandedSubGroup.value === name ? null : name;
};
const navigateAndClose = to => {
router.push(to);
emit('close');
};
const isActive = child => props.activeChild?.name === child.name;
const getAccessibleSubChildren = children =>
children.filter(c => isAllowed(c.to));
const renderIcon = icon => ({
component: typeof icon === 'object' ? icon : Icon,
props: typeof icon === 'string' ? { icon } : null,
});
const transition = computed(() =>
skipTransition.value
? {}
: {
enterActiveClass: 'transition-all duration-200 ease-out',
enterFromClass: 'opacity-0 -translate-y-2 max-h-0',
enterToClass: 'opacity-100 translate-y-0 max-h-96',
leaveActiveClass: 'transition-all duration-150 ease-in',
leaveFromClass: 'opacity-100 translate-y-0 max-h-96',
leaveToClass: 'opacity-0 -translate-y-2 max-h-0',
}
);
const accessibleChildren = computed(() => {
return props.children.filter(child => {
if (child.children) {
return child.children.some(subChild => isAllowed(subChild.to));
}
return child.to && isAllowed(child.to);
});
});
onMounted(async () => {
await nextTick();
// Auto-expand subgroup if active child is inside it
if (props.activeChild) {
const parentGroup = props.children.find(child =>
child.children?.some(subChild => subChild.name === props.activeChild.name)
);
if (parentGroup) {
expandedSubGroup.value = parentGroup.name;
// Wait for the subgroup expansion to render before measuring height
await nextTick();
}
}
if (!props.triggerRect) return;
const viewportHeight = window.innerHeight;
const popoverHeight = popoverRef.value?.offsetHeight || 300;
const { top: triggerTop } = props.triggerRect;
// Adjust position if popover would overflow viewport
topPosition.value =
triggerTop + popoverHeight > viewportHeight - 20
? Math.max(20, viewportHeight - popoverHeight - 20)
: triggerTop;
await nextTick();
skipTransition.value = false;
});
</script>
<template>
<TeleportWithDirection>
<div
ref="popoverRef"
class="fixed z-[100] min-w-[200px] max-w-[280px]"
:style="{
[isRTL ? 'right' : 'left']: `${sidebarWidth + 8}px`,
top: `${topPosition}px`,
}"
@mouseenter="emit('mouseenter')"
@mouseleave="emit('mouseleave')"
>
<div
class="bg-n-alpha-3 backdrop-blur-[100px] outline outline-1 -outline-offset-1 w-56 outline-n-weak rounded-xl shadow-lg py-2 px-2"
>
<div
class="px-2 py-1.5 text-xs font-medium text-n-slate-11 uppercase tracking-wider border-b border-n-weak mb-1"
>
{{ label }}
</div>
<ul
class="m-0 p-0 list-none max-h-[400px] overflow-y-auto no-scrollbar"
>
<template v-for="child in accessibleChildren" :key="child.name">
<!-- SubGroup with children -->
<li v-if="child.children" class="py-0.5">
<button
class="flex items-center gap-2 px-2 py-1.5 w-full rounded-lg text-n-slate-11 hover:bg-n-alpha-2 transition-colors duration-150 ease-out text-left rtl:text-right"
@click="toggleSubGroup(child.name)"
>
<Icon
v-if="child.icon"
:icon="child.icon"
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate text-sm">{{ child.label }}</span>
<span
class="size-3 transition-transform i-lucide-chevron-down"
:class="{
'rotate-180': expandedSubGroup === child.name,
}"
/>
</button>
<Transition v-bind="transition">
<ul
v-if="expandedSubGroup === child.name"
class="m-0 p-0 list-none ltr:pl-4 rtl:pr-4 mt-1 overflow-hidden"
>
<li
v-for="subChild in getAccessibleSubChildren(child.children)"
:key="subChild.name"
class="py-0.5"
>
<button
class="flex items-center gap-2 px-2 py-1.5 w-full rounded-lg text-sm text-left rtl:text-right transition-colors duration-150 ease-out"
:class="{
'text-n-slate-12 bg-n-alpha-2': isActive(subChild),
'text-n-slate-11 hover:bg-n-alpha-2':
!isActive(subChild),
}"
@click="navigateAndClose(subChild.to)"
>
<component
:is="renderIcon(subChild.icon).component"
v-if="subChild.icon"
v-bind="renderIcon(subChild.icon).props"
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate">{{ subChild.label }}</span>
</button>
</li>
</ul>
</Transition>
</li>
<!-- Direct child item -->
<li v-else class="py-0.5">
<button
class="flex items-center gap-2 px-2 py-1.5 w-full rounded-lg text-sm text-left rtl:text-right transition-colors duration-150 ease-out"
:class="{
'text-n-slate-12 bg-n-alpha-2': isActive(child),
'text-n-slate-11 hover:bg-n-alpha-2': !isActive(child),
}"
@click="navigateAndClose(child.to)"
>
<component
:is="renderIcon(child.icon).component"
v-if="child.icon"
v-bind="renderIcon(child.icon).props"
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate">{{ child.label }}</span>
</button>
</li>
</template>
</ul>
</div>
</div>
</TeleportWithDirection>
</template>
@@ -1,12 +1,14 @@
<script setup>
import { computed, onMounted, watch, nextTick } from 'vue';
import { useSidebarContext } from './provider';
import { computed, onMounted, onUnmounted, watch, nextTick, ref } from 'vue';
import { useSidebarContext, usePopoverState } from './provider';
import { useRoute, useRouter } from 'vue-router';
import Policy from 'dashboard/components/policy.vue';
import Icon from 'next/icon/Icon.vue';
import SidebarGroupHeader from './SidebarGroupHeader.vue';
import SidebarGroupLeaf from './SidebarGroupLeaf.vue';
import SidebarSubGroup from './SidebarSubGroup.vue';
import SidebarGroupEmptyLeaf from './SidebarGroupEmptyLeaf.vue';
import SidebarCollapsedPopover from './SidebarCollapsedPopover.vue';
const props = defineProps({
name: { type: String, required: true },
@@ -25,8 +27,18 @@ const {
resolvePermissions,
resolveFeatureFlag,
isAllowed,
isCollapsed,
isResizing,
} = useSidebarContext();
const {
activePopover,
setActivePopover,
closeActivePopover,
scheduleClose,
cancelClose,
} = usePopoverState();
const navigableChildren = computed(() => {
return props.children?.flatMap(child => child.children || child) || [];
});
@@ -39,6 +51,54 @@ const hasChildren = computed(
() => Array.isArray(props.children) && props.children.length > 0
);
// Use shared popover state - only one popover can be open at a time
const isPopoverOpen = computed(() => activePopover.value === props.name);
const triggerRef = ref(null);
const triggerRect = ref({ top: 0, left: 0, bottom: 0, right: 0 });
const openPopover = () => {
if (triggerRef.value) {
const rect = triggerRef.value.getBoundingClientRect();
triggerRect.value = {
top: rect.top,
left: rect.left,
bottom: rect.bottom,
right: rect.right,
};
}
setActivePopover(props.name);
};
const closePopover = () => {
if (activePopover.value === props.name) {
closeActivePopover();
}
};
const handleMouseEnter = () => {
if (!hasChildren.value || isResizing.value) return;
cancelClose();
openPopover();
};
const handleMouseLeave = () => {
if (!hasChildren.value) return;
scheduleClose(200);
};
const handlePopoverMouseEnter = () => {
cancelClose();
};
const handlePopoverMouseLeave = () => {
scheduleClose(100);
};
// Close popover when mouse leaves the window
const handleWindowBlur = () => {
closeActivePopover();
};
const accessibleItems = computed(() => {
if (!hasChildren.value) return [];
return props.children.filter(child => {
@@ -107,6 +167,13 @@ const hasActiveChild = computed(() => {
return activeChild.value !== undefined;
});
const handleCollapsedClick = () => {
if (hasChildren.value && hasAccessibleChildren.value) {
const firstItem = accessibleItems.value[0];
router.push(firstItem.to);
}
};
const toggleTrigger = () => {
if (
hasAccessibleChildren.value &&
@@ -125,6 +192,13 @@ onMounted(async () => {
if (hasActiveChild.value) {
setExpandedItem(props.name);
}
window.addEventListener('blur', handleWindowBlur);
document.addEventListener('mouseleave', handleWindowBlur);
});
onUnmounted(() => {
window.removeEventListener('blur', handleWindowBlur);
document.removeEventListener('mouseleave', handleWindowBlur);
});
watch(
@@ -145,45 +219,82 @@ watch(
:permissions="resolvePermissions(to)"
:feature-flag="resolveFeatureFlag(to)"
as="li"
class="grid gap-1 text-sm cursor-pointer select-none"
class="grid gap-1 text-sm cursor-pointer select-none min-w-0"
>
<SidebarGroupHeader
:icon
:name
:label
:to
:getter-keys="getterKeys"
:is-active="isActive"
:has-active-child="hasActiveChild"
:expandable="hasChildren"
:is-expanded="isExpanded"
@toggle="toggleTrigger"
/>
<ul
v-if="hasChildren"
v-show="isExpanded || hasActiveChild"
class="grid m-0 list-none sidebar-group-children"
>
<template v-for="child in children" :key="child.name">
<SidebarSubGroup
v-if="child.children"
:label="child.label"
:icon="child.icon"
:children="child.children"
:is-expanded="isExpanded"
<!-- Collapsed State -->
<template v-if="isCollapsed">
<div
class="relative"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<component
:is="to && !hasChildren ? 'router-link' : 'button'"
ref="triggerRef"
:to="to && !hasChildren ? to : undefined"
type="button"
class="flex items-center justify-center size-10 rounded-lg"
:class="{
'text-n-slate-12 bg-n-alpha-2': isActive || hasActiveChild,
'text-n-slate-11 hover:bg-n-alpha-2': !isActive && !hasActiveChild,
}"
:title="label"
@click="hasChildren ? handleCollapsedClick() : undefined"
>
<Icon v-if="icon" :icon="icon" class="size-4" />
</component>
<SidebarCollapsedPopover
v-if="hasChildren && isPopoverOpen"
:label="label"
:children="children"
:active-child="activeChild"
:trigger-rect="triggerRect"
@close="closePopover"
@mouseenter="handlePopoverMouseEnter"
@mouseleave="handlePopoverMouseLeave"
/>
<SidebarGroupLeaf
v-else-if="isAllowed(child.to)"
v-show="isExpanded || activeChild?.name === child.name"
v-bind="child"
:active="activeChild?.name === child.name"
/>
</template>
</ul>
<ul v-else-if="isExpandable && isExpanded">
<SidebarGroupEmptyLeaf />
</ul>
</div>
</template>
<!-- Expanded State -->
<template v-else>
<SidebarGroupHeader
:icon
:name
:label
:to
:getter-keys="getterKeys"
:is-active="isActive"
:has-active-child="hasActiveChild"
:expandable="hasChildren"
:is-expanded="isExpanded"
@toggle="toggleTrigger"
/>
<ul
v-if="hasChildren"
v-show="isExpanded || hasActiveChild"
class="grid m-0 list-none sidebar-group-children min-w-0"
>
<template v-for="child in children" :key="child.name">
<SidebarSubGroup
v-if="child.children"
:label="child.label"
:icon="child.icon"
:children="child.children"
:is-expanded="isExpanded"
:active-child="activeChild"
/>
<SidebarGroupLeaf
v-else-if="isAllowed(child.to)"
v-show="isExpanded || activeChild?.name === child.name"
v-bind="child"
:active="activeChild?.name === child.name"
/>
</template>
</ul>
<ul v-else-if="isExpandable && isExpanded">
<SidebarGroupEmptyLeaf />
</ul>
</template>
</Policy>
</template>
@@ -26,13 +26,13 @@ const count = computed(() =>
<template>
<component
:is="to ? 'router-link' : 'div'"
class="flex items-center gap-2 px-2 py-1.5 rounded-lg h-8 min-w-0"
class="flex items-center gap-2 px-1.5 py-1 rounded-lg h-8 min-w-0"
role="button"
draggable="false"
:to="to"
:title="label"
:class="{
'text-n-blue-text bg-n-alpha-2 font-medium': isActive && !hasActiveChild,
'text-n-slate-12 bg-n-alpha-2 font-medium': isActive && !hasActiveChild,
'text-n-slate-12 font-medium': hasActiveChild,
'text-n-slate-11 hover:bg-n-alpha-2': !isActive && !hasActiveChild,
}"
@@ -45,15 +45,21 @@ const count = computed(() =>
class="size-2 -top-px ltr:-right-px rtl:-left-px bg-n-brand absolute rounded-full border border-n-solid-2"
/>
</div>
<div class="flex items-center gap-1.5 flex-grow min-w-0">
<span class="text-sm font-medium leading-5 truncate">
<div class="flex items-center gap-1.5 flex-grow min-w-0 flex-1">
<span
class="truncate"
:class="{
'text-body-main': !isActive,
'font-medium text-sm': isActive || hasActiveChild,
}"
>
{{ label }}
</span>
<span
v-if="dynamicCount && !expandable"
class="rounded-md capitalize text-xs leading-5 font-medium text-center outline outline-1 px-1 flex-shrink-0"
:class="{
'text-n-blue-text outline-n-slate-6': isActive,
'text-n-slate-12 outline-n-slate-6': isActive,
'text-n-slate-11 outline-n-strong': !isActive,
}"
>
@@ -25,15 +25,15 @@ const shouldRenderComponent = computed(() => {
:permissions="resolvePermissions(to)"
:feature-flag="resolveFeatureFlag(to)"
as="li"
class="py-0.5 ltr:pl-3 rtl:pr-3 rtl:mr-3 ltr:ml-3 relative text-n-slate-11 child-item before:bg-n-slate-4 after:bg-transparent after:border-n-slate-4 before:left-0 rtl:before:right-0"
class="py-0.5 ltr:pl-2 rtl:pr-2 rtl:mr-3 ltr:ml-3 relative text-n-slate-11 child-item before:bg-n-slate-4 after:bg-transparent after:border-n-slate-4 before:left-0 rtl:before:right-0 min-w-0"
>
<component
:is="to ? 'router-link' : 'div'"
:to="to"
:title="label"
class="flex h-8 items-center gap-2 px-2 py-1 rounded-lg max-w-[9.438rem] hover:bg-gradient-to-r from-transparent via-n-slate-3/70 to-n-slate-3/70 group"
class="flex h-8 items-center gap-2 px-2 py-1 rounded-lg hover:bg-gradient-to-r from-transparent via-n-slate-3/70 to-n-slate-3/70 group min-w-0"
:class="{
'text-n-blue-text bg-n-alpha-2 active': active,
'text-n-slate-12 bg-n-alpha-2 active': active,
}"
>
<component
@@ -15,6 +15,10 @@ import {
} from 'next/dropdown-menu/base';
import CustomBrandPolicyWrapper from '../../components/CustomBrandPolicyWrapper.vue';
defineProps({
isCollapsed: { type: Boolean, default: false },
});
const emit = defineEmits(['close', 'openKeyShortcutModal']);
defineOptions({
@@ -120,11 +124,19 @@ const allowedMenuItems = computed(() => {
</script>
<template>
<DropdownContainer class="relative w-full min-w-0" @close="emit('close')">
<DropdownContainer
class="relative min-w-0"
:class="isCollapsed ? 'w-auto' : 'w-full'"
@close="emit('close')"
>
<template #trigger="{ toggle, isOpen }">
<button
class="flex gap-2 items-center p-1 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-1"
:class="{ 'bg-n-alpha-1': isOpen }"
class="flex gap-2 items-center p-1 text-left rounded-lg cursor-pointer hover:bg-n-alpha-1"
:class="[
{ 'bg-n-alpha-1': isOpen },
isCollapsed ? 'justify-center' : 'w-full',
]"
:title="isCollapsed ? currentUser.available_name : undefined"
@click="toggle"
>
<Avatar
@@ -135,7 +147,7 @@ const allowedMenuItems = computed(() => {
class="flex-shrink-0"
rounded-full
/>
<div class="min-w-0">
<div v-if="!isCollapsed" class="min-w-0">
<div class="text-sm font-medium leading-4 truncate text-n-slate-12">
{{ currentUser.available_name }}
</div>
@@ -48,11 +48,15 @@ useEventListener(scrollableContainer, 'scroll', () => {
:icon
class="my-1"
/>
<ul v-if="children.length" class="m-0 list-none reset-base relative group">
<ul
v-if="children.length"
class="m-0 list-none reset-base relative group min-w-0"
>
<!-- Each element has h-8, which is 32px, we will show 7 items with one hidden at the end,
which is 14rem. Then we add 16px so that we have some text visible from the next item -->
<div
ref="scrollableContainer"
class="min-w-0"
:class="{
'max-h-[calc(14rem+16px)] overflow-y-scroll no-scrollbar': isScrollable,
}"
@@ -68,7 +72,7 @@ useEventListener(scrollableContainer, 'scroll', () => {
<div
v-if="isScrollable && isExpanded"
v-show="!scrollEnd"
class="absolute bg-gradient-to-t from-n-solid-2 w-full h-12 to-transparent -bottom-1 pointer-events-none flex items-end justify-end px-2 animate-fade-in-up"
class="absolute bg-gradient-to-t from-n-background w-full h-12 to-transparent -bottom-1 pointer-events-none flex items-end justify-end px-2 animate-fade-in-up"
>
<svg
width="16"
@@ -1,9 +1,87 @@
import { inject, provide } from 'vue';
import { inject, provide, ref, computed } from 'vue';
import { usePolicy } from 'dashboard/composables/usePolicy';
import { useRouter } from 'vue-router';
import { useUISettings } from 'dashboard/composables/useUISettings';
const SidebarControl = Symbol('SidebarControl');
const DEFAULT_WIDTH = 200;
const MIN_WIDTH = 56;
const COLLAPSED_THRESHOLD = 160;
const MAX_WIDTH = 320;
// Shared state for active popover (only one can be open at a time)
const activePopover = ref(null);
let globalCloseTimeout = null;
export function useSidebarResize() {
const { uiSettings, updateUISettings } = useUISettings();
const sidebarWidth = ref(uiSettings.value.sidebar_width || DEFAULT_WIDTH);
const isCollapsed = computed(() => sidebarWidth.value < COLLAPSED_THRESHOLD);
const setSidebarWidth = width => {
sidebarWidth.value = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, width));
};
const saveWidth = () => {
updateUISettings({ sidebar_width: sidebarWidth.value });
};
const snapToCollapsed = () => {
sidebarWidth.value = MIN_WIDTH;
updateUISettings({ sidebar_width: MIN_WIDTH });
};
const snapToExpanded = () => {
sidebarWidth.value = DEFAULT_WIDTH;
updateUISettings({ sidebar_width: DEFAULT_WIDTH });
};
return {
sidebarWidth,
isCollapsed,
setSidebarWidth,
saveWidth,
snapToCollapsed,
snapToExpanded,
MIN_WIDTH,
MAX_WIDTH,
COLLAPSED_THRESHOLD,
DEFAULT_WIDTH,
};
}
export function usePopoverState() {
const setActivePopover = name => {
clearTimeout(globalCloseTimeout);
activePopover.value = name;
};
const closeActivePopover = () => {
activePopover.value = null;
};
const scheduleClose = (delay = 150) => {
clearTimeout(globalCloseTimeout);
globalCloseTimeout = setTimeout(() => {
closeActivePopover();
}, delay);
};
const cancelClose = () => {
clearTimeout(globalCloseTimeout);
};
return {
activePopover,
setActivePopover,
closeActivePopover,
scheduleClose,
cancelClose,
};
}
export function useSidebarContext() {
const context = inject(SidebarControl, null);
if (context === null) {
@@ -11,7 +89,6 @@ export function useSidebarContext() {
}
const router = useRouter();
const { shouldShow } = usePolicy();
const resolvePath = to => {
@@ -27,7 +27,7 @@ const updateValue = () => {
>
<span class="sr-only">{{ t('SWITCH.TOGGLE') }}</span>
<span
class="absolute top-0.5 left-0.5 h-3 w-3 transform rounded-full shadow-sm transition-transform duration-200 ease-in-out"
class="absolute top-0.5 left-0.5 h-3 w-3 transform rounded-full shadow-sm transition-transform duration-200 ease-out"
:class="
modelValue
? 'translate-x-3 bg-n-background'
@@ -84,7 +84,7 @@ const showDivider = index => {
class="relative z-10 px-4 truncate py-1.5 text-sm border-0 outline-1 outline-transparent rounded-lg transition-all duration-200 ease-out hover:text-n-brand active:scale-[1.02]"
:class="[
activeTab === index
? 'text-n-blue-text scale-100'
? 'text-n-blue-11 scale-100'
: 'text-n-slate-10 scale-[0.98]',
]"
@click="selectTab(index)"