Skip to content

Commit b688eed

Browse files
committed
feat: add NeDropdown component
1 parent 793b8fe commit b688eed

File tree

3 files changed

+259
-0
lines changed

3 files changed

+259
-0
lines changed

src/components/NeDropdown.vue

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
<!--
2+
Copyright (C) 2024 Nethesis S.r.l.
3+
SPDX-License-Identifier: GPL-3.0-or-later
4+
-->
5+
6+
<script lang="ts" setup>
7+
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
8+
import NeButton from './NeButton.vue'
9+
import { library } from '@fortawesome/fontawesome-svg-core'
10+
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
11+
import { faEllipsisVertical as fasEllipsisVertical } from '@fortawesome/free-solid-svg-icons'
12+
import { ref, watch } from 'vue'
13+
14+
export interface Props {
15+
items: NeDropdownItem[]
16+
alignToRight: boolean
17+
openMenuAriaLabel?: string
18+
}
19+
20+
const props = withDefaults(defineProps<Props>(), {
21+
items: () => [],
22+
alignToRight: false,
23+
openMenuAriaLabel: 'Open menu'
24+
})
25+
26+
export interface NeDropdownItem {
27+
id: string
28+
label?: string
29+
icon?: string
30+
iconStyle?: string
31+
danger?: boolean
32+
action?: () => void
33+
disabled?: boolean
34+
}
35+
36+
library.add(fasEllipsisVertical)
37+
38+
function onItemClick(item: NeDropdownItem) {
39+
if (!item.disabled && item.action) {
40+
item.action()
41+
}
42+
}
43+
44+
function getMenuItemActiveClasses(item: NeDropdownItem) {
45+
if (item.danger) {
46+
return 'bg-rose-700 text-white dark:bg-rose-600 dark:text-white'
47+
} else {
48+
return 'bg-gray-100 dark:bg-gray-800'
49+
}
50+
}
51+
52+
const top = ref(0)
53+
const left = ref(0)
54+
const right = ref(0)
55+
const buttonRef = ref<InstanceType<typeof MenuButton> | null>(null)
56+
57+
function calculatePosition() {
58+
top.value = buttonRef.value?.$el.getBoundingClientRect().bottom + window.scrollY
59+
left.value = buttonRef.value?.$el.getBoundingClientRect().left - window.scrollX
60+
right.value =
61+
document.documentElement.clientWidth -
62+
buttonRef.value?.$el.getBoundingClientRect().right -
63+
window.scrollX
64+
}
65+
66+
watch(
67+
() => props.alignToRight,
68+
() => {
69+
calculatePosition()
70+
}
71+
)
72+
</script>
73+
74+
<template>
75+
<Menu as="div" class="relative inline-block text-left">
76+
<MenuButton
77+
ref="buttonRef"
78+
class="flex items-center text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-50"
79+
@click="calculatePosition()"
80+
>
81+
<span class="sr-only">{{ openMenuAriaLabel }}</span>
82+
<slot name="button">
83+
<!-- default kebab button -->
84+
<NeButton class="py-2" kind="tertiary">
85+
<font-awesome-icon
86+
:icon="['fas', 'ellipsis-vertical']"
87+
aria-hidden="true"
88+
class="h-5 w-5 shrink-0"
89+
/>
90+
</NeButton>
91+
</slot>
92+
</MenuButton>
93+
<Teleport to="body">
94+
<transition
95+
enter-active-class="transition ease-out duration-100"
96+
enter-from-class="transform opacity-0 scale-95"
97+
enter-to-class="transform opacity-100 scale-100"
98+
leave-active-class="transition ease-in duration-75"
99+
leave-from-class="transform opacity-100 scale-100"
100+
leave-to-class="transform opacity-0 scale-95"
101+
>
102+
<MenuItems
103+
:style="[
104+
{ top: top + 'px' },
105+
alignToRight ? { right: right + 'px' } : { left: left + 'px' }
106+
]"
107+
class="absolute z-50 mt-2.5 min-w-[10rem] rounded-md bg-white py-2 shadow-lg ring-1 ring-gray-900/5 focus:outline-none dark:bg-gray-950 dark:ring-gray-500/50"
108+
>
109+
<template v-for="item in items" :key="item.id">
110+
<!-- divider -->
111+
<hr
112+
v-if="item.id.includes('divider')"
113+
class="my-1 border-gray-200 dark:border-gray-700"
114+
/>
115+
<!-- item -->
116+
<MenuItem v-else v-slot="{ active }" :disabled="item.disabled">
117+
<a
118+
:class="[
119+
active ? getMenuItemActiveClasses(item) : '',
120+
'group flex items-center px-4 py-2 text-sm',
121+
item.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
122+
item.danger
123+
? 'text-rose-700 dark:text-rose-500'
124+
: 'text-gray-700 dark:text-gray-50'
125+
]"
126+
@click="onItemClick(item)"
127+
>
128+
<font-awesome-icon
129+
v-if="item.icon"
130+
:icon="[item.iconStyle || 'fas', item.icon]"
131+
aria-hidden="true"
132+
class="mr-2 h-5 w-5 shrink-0"
133+
/>
134+
{{ item.label }}
135+
</a>
136+
</MenuItem>
137+
</template>
138+
</MenuItems>
139+
</transition>
140+
</Teleport>
141+
</Menu>
142+
</template>

src/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export { default as NeTableBody } from '@/components/NeTableBody.vue'
2222
export { default as NeTableRow } from '@/components/NeTableRow.vue'
2323
export { default as NeTableCell } from '@/components/NeTableCell.vue'
2424
export { default as NeCombobox } from '@/components/NeCombobox.vue'
25+
export { default as NeDropdown } from '@/components/NeDropdown.vue'
2526

2627
// types
2728
export type { NeComboboxOption } from '@/components/NeCombobox.vue'

stories/NeDropdown.stories.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Copyright (C) 2024 Nethesis S.r.l.
2+
// SPDX-License-Identifier: GPL-3.0-or-later
3+
4+
import type { Meta, StoryObj } from '@storybook/vue3'
5+
6+
import { NeDropdown, NeButton } from '../src/main'
7+
import { library } from '@fortawesome/fontawesome-svg-core'
8+
import { faPenToSquare as fasPenToSquare } from '@fortawesome/free-solid-svg-icons'
9+
import { faFloppyDisk as fasFloppyDisk } from '@fortawesome/free-solid-svg-icons'
10+
import { faTrashCan as fasTrashCan } from '@fortawesome/free-solid-svg-icons'
11+
import { faCopy as fasCopy } from '@fortawesome/free-solid-svg-icons'
12+
import { faChevronDown as fasChevronDown } from '@fortawesome/free-solid-svg-icons'
13+
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
14+
15+
library.add(fasPenToSquare)
16+
library.add(fasFloppyDisk)
17+
library.add(fasTrashCan)
18+
library.add(fasCopy)
19+
library.add(fasChevronDown)
20+
21+
const meta = {
22+
title: 'Visual/NeDropdown',
23+
component: NeDropdown,
24+
tags: ['autodocs'],
25+
args: {
26+
items: [
27+
{
28+
id: 'edit',
29+
label: 'Edit',
30+
icon: 'pen-to-square',
31+
iconStyle: 'fas',
32+
action: () => {}
33+
},
34+
{
35+
id: 'copy',
36+
label: 'Copy',
37+
icon: 'copy',
38+
iconStyle: 'fas',
39+
action: () => {}
40+
},
41+
{
42+
id: 'save',
43+
label: 'Save',
44+
icon: 'floppy-disk',
45+
iconStyle: 'fas',
46+
action: () => {},
47+
disabled: true
48+
},
49+
{
50+
id: 'divider1'
51+
},
52+
{
53+
id: 'delete',
54+
label: 'Delete',
55+
icon: 'trash-can',
56+
iconStyle: 'fas',
57+
danger: true,
58+
action: () => {}
59+
}
60+
],
61+
alignToRight: false,
62+
openMenuAriaLabel: 'Open menu'
63+
}
64+
} satisfies Meta<typeof NeDropdown>
65+
66+
export default meta
67+
type Story = StoryObj<typeof meta>
68+
69+
const template = '<NeDropdown v-bind="args" />'
70+
71+
export const Default: Story = {
72+
render: (args) => ({
73+
components: { NeDropdown },
74+
setup() {
75+
return { args }
76+
},
77+
template: template
78+
}),
79+
args: {}
80+
}
81+
82+
const alignToRightTemplate = '<NeDropdown v-bind="args" class="ml-48" />'
83+
84+
export const AlignToRight: Story = {
85+
render: (args) => ({
86+
components: { NeDropdown },
87+
setup() {
88+
return { args }
89+
},
90+
template: alignToRightTemplate
91+
}),
92+
args: { alignToRight: true }
93+
}
94+
95+
const withSlotTemplate =
96+
'<NeDropdown v-bind="args">\
97+
<template #button>\
98+
<NeButton>\
99+
<template #suffix>\
100+
<font-awesome-icon :icon="[\'fas\', \'chevron-down\']" class="h-4 w-4" aria-hidden="true" />\
101+
</template>\
102+
Menu\
103+
</NeButton>\
104+
</template>\
105+
</NeDropdown>'
106+
107+
export const WithSlot: Story = {
108+
render: (args) => ({
109+
components: { NeDropdown, NeButton, FontAwesomeIcon },
110+
setup() {
111+
return { args }
112+
},
113+
template: withSlotTemplate
114+
}),
115+
args: {}
116+
}

0 commit comments

Comments
 (0)