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);
}