feat: Add dropdown component (#10358)

This PR adds dropdown primitives to help compose custom dropdowns across the app. The following the sample usage

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
Shivam Mishra
2024-11-19 06:59:27 +05:30
committed by GitHub
parent 54afed9fb4
commit aaa328be87
22 changed files with 497 additions and 224 deletions

View File

@@ -0,0 +1,79 @@
<script setup>
import { ref } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownContainer from './base/DropdownContainer.vue';
import DropdownBody from './base/DropdownBody.vue';
import DropdownSection from './base/DropdownSection.vue';
import DropdownItem from './base/DropdownItem.vue';
import DropdownSeparator from './base/DropdownSeparator.vue';
import WootSwitch from 'components/ui/Switch.vue';
const currentUserAutoOffline = ref(false);
const menuItems = ref([
{
label: 'Contact Support',
icon: 'i-lucide-life-buoy',
click: () => window.alert('Contact Support'),
},
{
label: 'Keyboard Shortcuts',
icon: 'i-lucide-keyboard',
click: () => window.alert('Keyboard Shortcuts'),
},
{
label: 'Profile Settings',
icon: 'i-lucide-user-pen',
click: () => window.alert('Profile Settings'),
},
{
label: 'Change Appearance',
icon: 'i-lucide-swatch-book',
click: () => window.alert('Change Appearance'),
},
{
label: 'Open SuperAdmin',
icon: 'i-lucide-castle',
link: '/super_admin',
target: '_blank',
},
{
label: 'Log Out',
icon: 'i-lucide-log-out',
click: () => window.alert('Log Out'),
},
]);
</script>
<template>
<Story
title="Components/DropdownPrimitives"
:layout="{ type: 'grid', width: 400, height: 800 }"
>
<Variant title="Profile Menu">
<div class="p-4 bg-white h-[500px] dark:bg-slate-900">
<DropdownContainer>
<template #trigger="{ toggle }">
<Button label="Open Menu" size="sm" @click="toggle" />
</template>
<DropdownBody class="w-80">
<DropdownSection title="Profile Options">
<DropdownItem label="Contact Support" class="justify-between">
<span>{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}</span>
<div class="flex-shrink-0">
<WootSwitch v-model="currentUserAutoOffline" />
</div>
</DropdownItem>
</DropdownSection>
<DropdownSeparator />
<DropdownItem
v-for="item in menuItems"
:key="item.label"
v-bind="item"
/>
</DropdownBody>
</DropdownContainer>
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,9 @@
<template>
<div class="absolute">
<ul
class="text-sm bg-n-solid-1 border border-n-weak rounded-xl shadow-sm py-2 n-dropdown-body gap-2 grid list-none px-2 reset-base"
>
<slot />
</ul>
</div>
</template>

View File

@@ -0,0 +1,29 @@
<script setup>
import { useToggle } from '@vueuse/core';
import { provideDropdownContext } from './provider.js';
const emit = defineEmits(['close']);
const [isOpen, toggle] = useToggle(false);
const closeMenu = () => {
if (isOpen.value) {
emit('close');
toggle(false);
}
};
provideDropdownContext({
isOpen,
toggle,
closeMenu,
});
</script>
<template>
<div class="relative z-20 space-y-2">
<slot name="trigger" :is-open :toggle="() => toggle()" />
<div v-if="isOpen" v-on-clickaway="closeMenu" class="absolute">
<slot />
</div>
</div>
</template>

View File

@@ -0,0 +1,55 @@
<script setup>
import { computed } from 'vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { useDropdownContext } from './provider.js';
const props = defineProps({
label: { type: String, default: '' },
icon: { type: [String, Object, Function], default: '' },
link: { type: String, default: '' },
click: { type: Function, default: null },
preserveOpen: { type: Boolean, default: false },
});
defineOptions({
inheritAttrs: false,
});
const { closeMenu } = useDropdownContext();
const componentIs = computed(() => {
if (props.link) return 'router-link';
if (props.click) return 'button';
return 'div';
});
const triggerClick = () => {
if (props.click) {
props.click();
if (!props.preserveOpen) closeMenu();
}
};
</script>
<template>
<li class="n-dropdown-item">
<component
:is="componentIs"
v-bind="$attrs"
class="flex text-left rtl:text-right items-center p-2 reset-base text-sm text-n-slate-12 w-full border-0"
:class="{
'hover:bg-n-alpha-1 rounded-lg w-full gap-3': !$slots.default,
}"
:href="props.link || null"
@click="triggerClick"
>
<slot>
<slot name="icon">
<Icon v-if="icon" class="size-4 text-n-slate-11" :icon="icon" />
</slot>
<slot name="label">{{ label }}</slot>
</slot>
</component>
</li>
</template>

View File

@@ -0,0 +1,22 @@
<script setup>
defineProps({
title: {
type: String,
default: '',
},
});
</script>
<template>
<div class="-mx-2 n-dropdown-section">
<div
v-if="title"
class="px-4 mb-3 mt-1 leading-4 font-medium tracking-[0.2px] text-n-slate-10 text-xs"
>
{{ title }}
</div>
<ul class="gap-2 grid reset-base list-none px-2">
<slot />
</ul>
</div>
</template>

View File

@@ -0,0 +1,3 @@
<template>
<div class="h-0 border-b border-n-strong -mx-2" />
</template>

View File

@@ -0,0 +1,13 @@
import DropdownBody from './DropdownBody.vue';
import DropdownContainer from './DropdownContainer.vue';
import DropdownItem from './DropdownItem.vue';
import DropdownSection from './DropdownSection.vue';
import DropdownSeparator from './DropdownSeparator.vue';
export {
DropdownBody,
DropdownContainer,
DropdownItem,
DropdownSection,
DropdownSeparator,
};

View File

@@ -0,0 +1,19 @@
import { inject, provide } from 'vue';
const DropdownControl = Symbol('DropdownControl');
export function useDropdownContext() {
const context = inject(DropdownControl, null);
if (context === null) {
throw new Error(
`Component is missing a parent <DropdownContainer /> component.`
);
}
return context;
}
export function provideDropdownContext(context) {
provide(DropdownControl, context);
}

View File

@@ -1,24 +1,23 @@
<script setup>
import { useAccount } from 'dashboard/composables/useAccount';
import { useMapGetter } from 'dashboard/composables/store';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import ButtonNext from 'next/button/Button.vue';
import Icon from 'next/icon/Icon.vue';
import {
DropdownContainer,
DropdownBody,
DropdownSection,
DropdownItem,
} from 'next/dropdown-menu/base';
const emit = defineEmits(['showCreateAccountModal']);
const { t } = useI18n();
const { accountId, currentAccount } = useAccount();
const currentUser = useMapGetter('getCurrentUser');
const globalConfig = useMapGetter('globalConfig/get');
const [showDropdown, toggleDropdown] = useToggle(false);
const close = () => {
if (showDropdown.value) {
toggleDropdown(false);
}
};
const onChangeAccount = newId => {
const accountUrl = `/app/accounts/${newId}/dashboard`;
@@ -26,51 +25,45 @@ const onChangeAccount = newId => {
};
const emitNewAccount = () => {
close();
emit('showCreateAccountModal');
};
</script>
<template>
<div class="relative z-20">
<button
id="sidebar-account-switcher"
:data-account-id="accountId"
aria-haspopup="listbox"
aria-controls="account-options"
class="flex items-center gap-2 justify-between w-full rounded-lg hover:bg-n-alpha-1 px-2"
:class="{ 'bg-n-alpha-1': showDropdown }"
@click="toggleDropdown()"
>
<span
class="text-sm font-medium leading-5 text-n-slate-12 truncate"
aria-live="polite"
<DropdownContainer>
<template #trigger="{ toggle, isOpen }">
<button
id="sidebar-account-switcher"
:data-account-id="accountId"
aria-haspopup="listbox"
aria-controls="account-options"
class="flex items-center gap-2 justify-between w-full rounded-lg hover:bg-n-alpha-1 px-2"
:class="{ 'bg-n-alpha-1': isOpen }"
@click="toggle"
>
{{ currentAccount.name }}
</span>
<span
aria-hidden="true"
class="i-lucide-chevron-down size-4 text-n-slate-10 flex-shrink-0"
/>
</button>
<div v-if="showDropdown" v-on-clickaway="close" class="absolute top-8 z-50">
<div
class="min-w-72 max-w-96 text-sm bg-n-solid-1 border border-n-weak rounded-xl shadow-sm py-4 px-2 flex flex-col gap-2"
>
<div
class="px-4 leading-4 font-medium tracking-[0.2px] text-n-slate-10 text-xs"
<span
class="text-sm font-medium leading-5 text-n-slate-12 truncate"
aria-live="polite"
>
{{ t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS') }}
</div>
<div class="px-1 gap-1 grid">
<button
v-for="account in currentUser.accounts"
:id="`account-${account.id}`"
:key="account.id"
class="flex w-full hover:bg-n-alpha-1 space-x-4"
@click="onChangeAccount(account.id)"
>
{{ currentAccount.name }}
</span>
<span
aria-hidden="true"
class="i-lucide-chevron-down size-4 text-n-slate-10 flex-shrink-0"
/>
</button>
</template>
<DropdownBody class="min-w-80">
<DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_WORKSPACE')">
<DropdownItem
v-for="account in currentUser.accounts"
:id="`account-${account.id}`"
:key="account.id"
class="cursor-pointer"
@click="onChangeAccount(account.id)"
>
<template #label>
<div
:for="account.name"
class="text-left rtl:text-right flex gap-2 items-center"
@@ -98,19 +91,20 @@ const emitNewAccount = () => {
icon="i-lucide-check"
class="text-n-teal-11 size-5"
/>
</button>
</div>
<div v-if="globalConfig.createNewAccountFromDashboard" class="px-2">
<ButtonNext
variant="secondary"
class="w-full"
size="sm"
@click="emitNewAccount"
>
{{ t('CREATE_ACCOUNT.NEW_ACCOUNT') }}
</ButtonNext>
</div>
</div>
</div>
</div>
</template>
</DropdownItem>
</DropdownSection>
<DropdownItem v-if="globalConfig.createNewAccountFromDashboard">
<ButtonNext
color="slate"
variant="faded"
class="w-full"
size="sm"
@click="emitNewAccount"
>
{{ t('CREATE_ACCOUNT.NEW_ACCOUNT') }}
</ButtonNext>
</DropdownItem>
</DropdownBody>
</DropdownContainer>
</template>

View File

@@ -101,7 +101,7 @@ const toggleTrigger = () => {
:permissions="resolvePermissions(to)"
:feature-flag="resolveFeatureFlag(to)"
as="li"
class="text-sm cursor-pointer select-none gap-1 grid"
class="grid gap-1 text-sm cursor-pointer select-none"
>
<SidebarGroupHeader
:icon
@@ -117,7 +117,7 @@ const toggleTrigger = () => {
<ul
v-if="hasChildren"
v-show="isExpanded || hasActiveChild"
class="list-none m-0 grid sidebar-group-children"
class="grid m-0 list-none sidebar-group-children"
>
<template v-for="child in children" :key="child.name">
<SidebarSubGroup

View File

@@ -4,11 +4,16 @@ import Auth from 'dashboard/api/auth';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import Avatar from 'next/avatar/Avatar.vue';
import Icon from 'next/icon/Icon.vue';
import SidebarProfileMenuStatus from './SidebarProfileMenuStatus.vue';
import {
DropdownContainer,
DropdownBody,
DropdownSeparator,
DropdownItem,
} from 'next/dropdown-menu/base';
const emit = defineEmits(['close', 'openKeyShortcutModal']);
defineOptions({
@@ -21,14 +26,6 @@ const router = useRouter();
const globalConfig = useMapGetter('globalConfig/get');
const currentUser = useMapGetter('getCurrentUser');
const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
const [showProfileMenu, toggleProfileMenu] = useToggle(false);
const closeMenu = () => {
if (showProfileMenu.value) {
emit('close');
toggleProfileMenu(false);
}
};
const menuItems = computed(() => {
return [
@@ -37,7 +34,6 @@ const menuItems = computed(() => {
label: t('SIDEBAR_ITEMS.CONTACT_SUPPORT'),
icon: 'i-lucide-life-buoy',
click: () => {
closeMenu();
window.$chatwoot.toggle();
},
},
@@ -46,7 +42,6 @@ const menuItems = computed(() => {
label: t('SIDEBAR_ITEMS.KEYBOARD_SHORTCUTS'),
icon: 'i-lucide-keyboard',
click: () => {
closeMenu();
emit('openKeyShortcutModal');
},
},
@@ -55,20 +50,26 @@ const menuItems = computed(() => {
label: t('SIDEBAR_ITEMS.PROFILE_SETTINGS'),
icon: 'i-lucide-user-pen',
click: () => {
closeMenu();
router.push({ name: 'profile_settings_index' });
},
},
{
show: true,
label: t('SIDEBAR_ITEMS.APPEARANCE'),
icon: 'i-lucide-swatch-book',
icon: 'i-lucide-palette',
click: () => {
closeMenu();
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'appearance_settings' });
},
},
{
show: true,
label: t('SIDEBAR_ITEMS.DOCS'),
icon: 'i-lucide-book',
click: () => {
window.open('https://www.chatwoot.com/hc/user-guide/en', '_blank');
},
},
{
show: currentUser.value.type === 'SuperAdmin',
label: t('SIDEBAR_ITEMS.SUPER_ADMIN_CONSOLE'),
@@ -79,7 +80,7 @@ const menuItems = computed(() => {
{
show: true,
label: t('SIDEBAR_ITEMS.LOGOUT'),
icon: 'i-lucide-log-out',
icon: 'i-lucide-power',
click: Auth.logout,
},
];
@@ -91,56 +92,40 @@ const allowedMenuItems = computed(() => {
</script>
<template>
<div class="relative z-20 w-full min-w-0">
<button
class="flex gap-2 items-center rounded-lg cursor-pointer text-left w-full hover:bg-n-alpha-1 p-1"
v-bind="$attrs"
:class="{
'bg-n-alpha-1': showProfileMenu,
}"
@click="toggleProfileMenu"
>
<Avatar
:size="32"
:name="currentUser.available_name"
:src="currentUser.avatar_url"
:status="currentUserAvailability"
class="flex-shrink-0"
rounded-full
/>
<div class="min-w-0">
<div class="text-n-slate-12 text-sm leading-4 font-medium truncate">
{{ currentUser.available_name }}
</div>
<div class="text-n-slate-11 text-xs truncate">
{{ currentUser.email }}
</div>
</div>
</button>
<div
v-if="showProfileMenu"
v-on-clickaway="closeMenu"
class="absolute left-0 bottom-12 z-50"
>
<div
class="w-72 min-h-32 bg-n-solid-1 border border-n-weak rounded-xl shadow-sm"
<DropdownContainer
class="relative z-20 w-full min-w-0"
@close="emit('close')"
>
<template #trigger="{ toggle, isOpen }">
<button
class="flex gap-2 items-center rounded-lg cursor-pointer text-left w-full hover:bg-n-alpha-1 p-1"
:class="{ 'bg-n-alpha-1': isOpen }"
@click="toggle"
>
<SidebarProfileMenuStatus />
<div class="border-t border-n-strong mx-2 my-0" />
<ul class="list-none m-0 grid gap-1 p-1 text-n-slate-12">
<li v-for="item in allowedMenuItems" :key="item.label" class="m-0">
<component
:is="item.link ? 'a' : 'button'"
v-bind="item.link ? { target: item.target, href: item.link } : {}"
class="text-left hover:bg-n-alpha-1 px-2 py-1.5 w-full flex items-center gap-2"
@click="item.click"
>
<Icon :icon="item.icon" class="size-4" />
{{ item.label }}
</component>
</li>
</ul>
</div>
</div>
</div>
<Avatar
:size="32"
:name="currentUser.available_name"
:src="currentUser.avatar_url"
:status="currentUserAvailability"
class="flex-shrink-0"
rounded-full
/>
<div class="min-w-0">
<div class="text-n-slate-12 text-sm leading-4 font-medium truncate">
{{ currentUser.available_name }}
</div>
<div class="text-n-slate-11 text-xs truncate">
{{ currentUser.email }}
</div>
</div>
</button>
</template>
<DropdownBody class="left-0 bottom-12 z-50 w-80 mb-1">
<SidebarProfileMenuStatus />
<DropdownSeparator />
<template v-for="item in allowedMenuItems" :key="item.label">
<DropdownItem v-if="item.show" v-bind="item" />
</template>
</DropdownBody>
</DropdownContainer>
</template>

View File

@@ -1,11 +1,18 @@
<script setup>
import { computed } from 'vue';
import { computed, h } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import wootConstants from 'dashboard/constants/globals';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import {
DropdownContainer,
DropdownBody,
DropdownSection,
DropdownItem,
} from 'next/dropdown-menu/base';
import Icon from 'next/icon/Icon.vue';
import Button from 'next/button/Button.vue';
const { t } = useI18n();
const store = useStore();
@@ -22,14 +29,22 @@ const statusList = computed(() => {
];
});
const statusColors = ['bg-n-teal-9', 'bg-n-amber-9', 'bg-n-slate-9'];
const availabilityStatuses = computed(() => {
return statusList.value.map((statusLabel, index) => ({
label: statusLabel,
value: AVAILABILITY_STATUS_KEYS[index],
color: statusColors[index],
icon: h('span', { class: [statusColors[index], 'size-[12px] rounded'] }),
active: currentUserAvailability.value === AVAILABILITY_STATUS_KEYS[index],
}));
});
const activeStatus = computed(() => {
return availabilityStatuses.value.find(status => status.active);
});
function changeAvailabilityStatus(availability) {
try {
store.dispatch('updateAvailability', {
@@ -50,70 +65,58 @@ function updateAutoOffline(autoOffline) {
</script>
<template>
<div class="pt-2 text-n-slate-12">
<span
class="px-3 leading-4 font-medium tracking-[0.2px] text-n-slate-10 text-xs"
>
{{ t('SIDEBAR.SET_AVAILABILITY_TITLE') }}
</span>
<ul class="list-none m-0 grid gap-1 p-1">
<li
v-for="status in availabilityStatuses"
:key="status.value"
class="flex items-baseline"
>
<button
class="text-left rtl:text-right hover:bg-n-alpha-1 px-2 py-1.5 w-full flex items-center gap-2"
:class="{
'pointer-events-none bg-n-amber-10/10': status.active,
'bg-n-teal-3': status.active && status.value === 'online',
'bg-n-amber-3': status.active && status.value === 'busy',
'bg-n-slate-3': status.active && status.value === 'offline',
}"
@click="changeAvailabilityStatus(status.value)"
>
<div
class="rounded-full w-2.5 h-2.5"
:class="{
'bg-n-teal-10': status.value === 'online',
'bg-n-amber-10': status.value === 'busy',
'bg-n-slate-10': status.value === 'offline',
}"
/>
<span class="flex-grow">{{ status.label }}</span>
<Icon
v-if="status.active"
icon="i-lucide-check"
class="size-4 flex-shrink-0"
:class="{
'text-n-teal-11': status.value === 'online',
'text-n-amber-11': status.value === 'busy',
'text-n-slate-11': status.value === 'offline',
}"
/>
</button>
</li>
</ul>
</div>
<div class="border-t border-n-strong mx-2 my-0" />
<ul class="list-none m-0 grid gap-1 p-1">
<li class="px-2 py-1.5 flex items-start w-full gap-2">
<div class="h-5 flex items-center flex-shrink-0">
<Icon icon="i-lucide-info" class="size-4" />
</div>
<div class="flex-grow">
<div class="h-5 leading-none flex place-items-center text-n-slate-12">
<DropdownSection>
<div class="grid gap-0">
<DropdownItem>
<div class="flex-grow flex items-center gap-1">
{{ $t('SIDEBAR.SET_YOUR_AVAILABILITY') }}
</div>
<DropdownContainer>
<template #trigger="{ toggle }">
<Button
size="sm"
color="slate"
variant="faded"
class="min-w-[96px]"
icon="i-lucide-chevron-down"
trailing-icon
@click="toggle"
>
<div class="flex gap-1 items-center flex-grow text-sm">
<div class="p-1 flex-shrink-0">
<div class="size-2 rounded-sm" :class="activeStatus.color" />
</div>
<span>{{ activeStatus.label }}</span>
</div>
</Button>
</template>
<DropdownBody class="min-w-32">
<DropdownItem
v-for="status in availabilityStatuses"
:key="status.value"
:label="status.label"
:icon="status.icon"
class="cursor-pointer"
@click="changeAvailabilityStatus(status.value)"
/>
</DropdownBody>
</DropdownContainer>
</DropdownItem>
<DropdownItem>
<div class="flex-grow flex items-center gap-1">
{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}
<Icon
v-tooltip.top="$t('SIDEBAR.SET_AUTO_OFFLINE.INFO_SHORT')"
icon="i-lucide-info"
class="size-4 text-n-slate-10"
/>
</div>
<div class="text-xs leading-tight text-n-slate-10 mt-1">
{{ t('SIDEBAR.SET_AUTO_OFFLINE.INFO_SHORT') }}
</div>
</div>
<woot-switch
class="flex-shrink-0"
:model-value="currentUserAutoOffline"
@input="updateAutoOffline"
/>
</li>
</ul>
<woot-switch
class="flex-shrink-0"
:model-value="currentUserAutoOffline"
@input="updateAutoOffline"
/>
</DropdownItem>
</div>
</DropdownSection>
</template>

View File

@@ -52,7 +52,7 @@ useEventListener(scrollableContainer, 'scroll', () => {
:icon
class="my-1"
/>
<ul class="m-0 list-none relative group">
<ul class="m-0 list-none reset-base relative group">
<!-- 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