Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import DropdownMenu from './DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
const menuItems = [
|
||||
{ label: 'Edit', action: 'edit', icon: 'edit' },
|
||||
{ label: 'Publish', action: 'publish', icon: 'checkmark' },
|
||||
{ label: 'Archive', action: 'archive', icon: 'archive' },
|
||||
{ label: 'Delete', action: 'delete', icon: 'delete' },
|
||||
];
|
||||
const isOpen = ref(false);
|
||||
const toggleDropdown = () => {
|
||||
isOpen.value = !isOpen.value;
|
||||
};
|
||||
const handleAction = () => {
|
||||
isOpen.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Components/DropdownMenu" :layout="{ type: 'grid', width: 300 }">
|
||||
<Variant title="Default">
|
||||
<div class="p-4 bg-n-background h-72">
|
||||
<div class="relative">
|
||||
<Button label="Open Menu" size="sm" @click="toggleDropdown" />
|
||||
<DropdownMenu
|
||||
v-if="isOpen"
|
||||
:menu-items="menuItems"
|
||||
class="absolute left-0 top-10"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Always Open">
|
||||
<div class="p-4 bg-n-background h-72">
|
||||
<DropdownMenu :menu-items="menuItems" @action="handleAction" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Custom Items">
|
||||
<div class="p-4 bg-n-background h-72">
|
||||
<DropdownMenu
|
||||
:menu-items="[
|
||||
{ label: 'Custom 1', action: 'custom1', icon: 'file-upload' },
|
||||
{ label: 'Custom 2', action: 'custom2', icon: 'document' },
|
||||
{ label: 'Danger', action: 'delete', icon: 'delete' },
|
||||
]"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="With search">
|
||||
<div class="p-4 bg-n-background h-72">
|
||||
<DropdownMenu
|
||||
:menu-items="[
|
||||
{ label: 'Custom 1', action: 'custom1', icon: 'file-upload' },
|
||||
{ label: 'Custom 2', action: 'custom2', icon: 'document' },
|
||||
{ label: 'Danger', action: 'delete', icon: 'delete' },
|
||||
]"
|
||||
show-search
|
||||
@action="handleAction"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,266 @@
|
||||
<script setup>
|
||||
import { defineProps, ref, defineEmits, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
menuItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
validator: value => {
|
||||
return value.every(item => item.action && item.value && item.label);
|
||||
},
|
||||
},
|
||||
menuSections: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
thumbnailSize: {
|
||||
type: Number,
|
||||
default: 20,
|
||||
},
|
||||
showSearch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
searchPlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isSearching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
labelClass: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
disableLocalFiltering: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action', 'search']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const searchInput = ref(null);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const hasSections = computed(() => props.menuSections.length > 0);
|
||||
|
||||
const flattenedMenuItems = computed(() => {
|
||||
if (!hasSections.value) {
|
||||
return props.menuItems;
|
||||
}
|
||||
|
||||
return props.menuSections.flatMap(section => section.items || []);
|
||||
});
|
||||
|
||||
const filteredMenuItems = computed(() => {
|
||||
if (props.disableLocalFiltering) return props.menuItems;
|
||||
if (!searchQuery.value) return flattenedMenuItems.value;
|
||||
|
||||
return flattenedMenuItems.value.filter(item =>
|
||||
item.label.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
const filteredMenuSections = computed(() => {
|
||||
if (!hasSections.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (props.disableLocalFiltering || !searchQuery.value) {
|
||||
return props.menuSections;
|
||||
}
|
||||
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
|
||||
return props.menuSections
|
||||
.map(section => {
|
||||
const filteredItems = (section.items || []).filter(item =>
|
||||
item.label.toLowerCase().includes(query)
|
||||
);
|
||||
|
||||
return {
|
||||
...section,
|
||||
items: filteredItems,
|
||||
};
|
||||
})
|
||||
.filter(section => section.items.length > 0);
|
||||
});
|
||||
|
||||
const handleSearchInput = event => {
|
||||
if (props.disableLocalFiltering) {
|
||||
emit('search', event.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = item => {
|
||||
const { action, value, ...rest } = item;
|
||||
emit('action', { action, value, ...rest });
|
||||
};
|
||||
|
||||
const shouldShowEmptyState = computed(() => {
|
||||
if (hasSections.value) {
|
||||
return filteredMenuSections.value.length === 0;
|
||||
}
|
||||
|
||||
return filteredMenuItems.value.length === 0;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (searchInput.value && props.showSearch) {
|
||||
searchInput.value.focus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 gap-2 flex flex-col min-w-[136px] shadow-lg pb-2 px-2"
|
||||
:class="{
|
||||
'pt-2': !showSearch,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="showSearch"
|
||||
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2 z-20"
|
||||
>
|
||||
<div class="relative">
|
||||
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="
|
||||
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
@input="handleSearchInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="hasSections">
|
||||
<div
|
||||
v-for="(section, sectionIndex) in filteredMenuSections"
|
||||
:key="section.title || sectionIndex"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<p
|
||||
v-if="section.title"
|
||||
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky z-10 bg-n-alpha-3 backdrop-blur-sm"
|
||||
:class="showSearch ? 'top-10' : 'top-0'"
|
||||
>
|
||||
{{ section.title }}
|
||||
</p>
|
||||
<div
|
||||
v-if="section.isLoading"
|
||||
class="flex items-center justify-center py-2"
|
||||
>
|
||||
<Spinner :size="24" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!section.items.length && section.emptyState"
|
||||
class="text-sm text-n-slate-11 px-2 py-1.5"
|
||||
>
|
||||
{{ section.emptyState }}
|
||||
</div>
|
||||
<button
|
||||
v-for="(item, itemIndex) in section.items"
|
||||
:key="item.value || itemIndex"
|
||||
type="button"
|
||||
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
|
||||
:class="{
|
||||
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
|
||||
'text-n-ruby-11': item.action === 'delete',
|
||||
'text-n-slate-12': item.action !== 'delete',
|
||||
}"
|
||||
:disabled="item.disabled"
|
||||
@click="handleAction(item)"
|
||||
>
|
||||
<slot name="thumbnail" :item="item">
|
||||
<Avatar
|
||||
v-if="item.thumbnail"
|
||||
:name="item.thumbnail.name"
|
||||
:src="item.thumbnail.src"
|
||||
:size="thumbnailSize"
|
||||
rounded-full
|
||||
/>
|
||||
</slot>
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
class="flex-shrink-0 size-3.5"
|
||||
/>
|
||||
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
||||
<span
|
||||
v-if="item.label"
|
||||
class="min-w-0 text-sm truncate"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="sectionIndex < filteredMenuSections.length - 1"
|
||||
class="h-px bg-n-alpha-2 mx-2 my-1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
v-for="(item, index) in filteredMenuItems"
|
||||
:key="index"
|
||||
type="button"
|
||||
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
|
||||
:class="{
|
||||
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
|
||||
'text-n-ruby-11': item.action === 'delete',
|
||||
'text-n-slate-12': item.action !== 'delete',
|
||||
}"
|
||||
:disabled="item.disabled"
|
||||
@click="handleAction(item)"
|
||||
>
|
||||
<slot name="thumbnail" :item="item">
|
||||
<Avatar
|
||||
v-if="item.thumbnail"
|
||||
:name="item.thumbnail.name"
|
||||
:src="item.thumbnail.src"
|
||||
:size="thumbnailSize"
|
||||
rounded-full
|
||||
/>
|
||||
</slot>
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
class="flex-shrink-0 size-3.5"
|
||||
/>
|
||||
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
||||
<span
|
||||
v-if="item.label"
|
||||
class="min-w-0 text-sm truncate"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<div
|
||||
v-if="shouldShowEmptyState"
|
||||
class="text-sm text-n-slate-11 px-2 py-1.5"
|
||||
>
|
||||
{{
|
||||
isSearching
|
||||
? t('DROPDOWN_MENU.SEARCHING')
|
||||
: t('DROPDOWN_MENU.EMPTY_STATE')
|
||||
}}
|
||||
</div>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -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 ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
|
||||
const currentUserAutoOffline = ref(false);
|
||||
|
||||
const menuItems = ref([
|
||||
{
|
||||
label: 'Contact Support',
|
||||
icon: 'i-lucide-life-buoy',
|
||||
click: () => console.log('Contact Support'),
|
||||
},
|
||||
{
|
||||
label: 'Keyboard Shortcuts',
|
||||
icon: 'i-lucide-keyboard',
|
||||
click: () => console.log('Keyboard Shortcuts'),
|
||||
},
|
||||
{
|
||||
label: 'Profile Settings',
|
||||
icon: 'i-lucide-user-pen',
|
||||
click: () => console.log('Profile Settings'),
|
||||
},
|
||||
{
|
||||
label: 'Change Appearance',
|
||||
icon: 'i-lucide-swatch-book',
|
||||
click: () => console.log('Change Appearance'),
|
||||
},
|
||||
{
|
||||
label: 'Open SuperAdmin',
|
||||
icon: 'i-lucide-castle',
|
||||
link: '/super_admin',
|
||||
target: '_blank',
|
||||
},
|
||||
{
|
||||
label: 'Log Out',
|
||||
icon: 'i-lucide-log-out',
|
||||
click: () => console.log('Log Out'),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/DropdownPrimitives"
|
||||
:layout="{ type: 'grid', width: 400, height: 800 }"
|
||||
>
|
||||
<Variant title="Profile Menu">
|
||||
<div class="p-4 bg-n-background h-[500px]">
|
||||
<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">
|
||||
<ToggleSwitch 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>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
const { strong } = defineProps({
|
||||
// Use strong prop when this dropdown is stacked inside another dropdown
|
||||
// Chrome has issues with stacked backdrop-blur, so we need an extra blur layer when stacked
|
||||
// Also, stacked dropdowns should have a strong border
|
||||
strong: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const borderClass = computed(() => {
|
||||
return strong ? 'border-n-strong' : 'border-n-weak';
|
||||
});
|
||||
|
||||
const beforeClass = computed(() => {
|
||||
if (!strong) return '';
|
||||
|
||||
// Add extra blur layer only when strong prop is true, as a hack for Chrome's stacked backdrop-blur limitation
|
||||
// https://issues.chromium.org/issues/40835530
|
||||
return "before:content-['\x00A0'] before:absolute before:bottom-0 before:left-0 before:w-full before:h-full before:rounded-xl before:backdrop-contrast-70 before:backdrop-blur-sm before:z-0 [&>*]:relative";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="absolute">
|
||||
<ul
|
||||
class="text-sm bg-n-alpha-3 backdrop-blur-[100px] border rounded-xl shadow-sm py-2 n-dropdown-body gap-2 grid list-none px-2 reset-base relative"
|
||||
:class="[borderClass, beforeClass]"
|
||||
>
|
||||
<slot />
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
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 v-on-click-outside="closeMenu" class="relative space-y-2">
|
||||
<slot name="trigger" :is-open :toggle="() => toggle()" />
|
||||
<div v-if="isOpen" class="absolute">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<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, Object], default: '' },
|
||||
nativeLink: { type: Boolean, default: false },
|
||||
click: { type: Function, default: null },
|
||||
preserveOpen: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const { closeMenu } = useDropdownContext();
|
||||
|
||||
const componentIs = computed(() => {
|
||||
if (props.link) {
|
||||
if (props.nativeLink && typeof props.link === 'string') {
|
||||
return 'a';
|
||||
}
|
||||
|
||||
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-2 rounded-lg w-full gap-3': !$slots.default,
|
||||
}"
|
||||
:href="componentIs === 'a' ? props.link : null"
|
||||
:to="componentIs === 'router-link' ? 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>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: 'max-h-96',
|
||||
},
|
||||
});
|
||||
</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 overflow-y-auto"
|
||||
:class="height"
|
||||
>
|
||||
<slot />
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div class="h-0 border-b border-n-strong -mx-2" />
|
||||
</template>
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user