@nexxtmove/ui 0.1.28 → 0.1.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +50 -8
- package/dist/index.js +1256 -951
- package/dist/nuxt.js +22 -19
- package/package.json +1 -1
- package/src/components/Button/Button.vue +4 -4
- package/src/components/Checkbox/Checkbox.stories.ts +35 -0
- package/src/components/Checkbox/Checkbox.test.ts +55 -0
- package/src/components/Checkbox/Checkbox.vue +46 -0
- package/src/components/DropdownButton/DropdownButton.stories.ts +68 -0
- package/src/components/DropdownButton/DropdownButton.test.ts +124 -0
- package/src/components/DropdownButton/DropdownButton.vue +159 -0
- package/src/components/Table/Table.stories.ts +283 -0
- package/src/components/Table/Table.test.ts +220 -0
- package/src/components/Table/Table.vue +227 -0
- package/src/components.json +3 -0
- package/src/index.ts +3 -0
package/dist/nuxt.js
CHANGED
|
@@ -1,24 +1,27 @@
|
|
|
1
|
-
import { defineNuxtModule as
|
|
2
|
-
const a = "components/AnimatedNumber/AnimatedNumber.vue", m = "components/Button/Button.vue", x = "components/Calendar/Calendar.vue", u = "components/Chip/Chip.vue",
|
|
1
|
+
import { defineNuxtModule as c, addComponent as s, addTemplate as i } from "@nuxt/kit";
|
|
2
|
+
const a = "components/AnimatedNumber/AnimatedNumber.vue", m = "components/Button/Button.vue", x = "components/Calendar/Calendar.vue", u = "components/Checkbox/Checkbox.vue", p = "components/Chip/Chip.vue", l = "components/CustomerJourneyTile/CustomerJourneyTile.vue", r = "components/DatePicker/DatePicker.vue", d = "components/DropdownButton/DropdownButton.vue", v = "components/Header/Header.vue", N = "components/Icon/Icon.vue", T = "components/InfoBlock/InfoBlock.vue", C = "components/ProgressBar/ProgressBar.vue", S = "components/SocialIcons/SocialIcons.vue", b = "components/SocialMediaCustomTemplate/SocialMediaCustomTemplate.vue", f = "components/SocialMediaTemplate/SocialMediaTemplate.vue", h = "components/SocialMediaType/SocialMediaType.vue", k = "components/StepperHeader/StepperHeader.vue", B = "components/Table/Table.vue", M = "components/TimelineEvent/TimelineEvent.vue", P = "components/TimelinePhaseblock/TimelinePhaseblock.vue", I = "components/Tooltip/Tooltip.vue", y = {
|
|
3
3
|
NexxtAnimatedNumber: a,
|
|
4
4
|
NexxtButton: m,
|
|
5
5
|
NexxtCalendar: x,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
6
|
+
NexxtCheckbox: u,
|
|
7
|
+
NexxtChip: p,
|
|
8
|
+
NexxtCustomerJourneyTile: l,
|
|
9
|
+
NexxtDatePicker: r,
|
|
10
|
+
NexxtDropdownButton: d,
|
|
11
|
+
NexxtHeader: v,
|
|
12
|
+
NexxtIcon: N,
|
|
13
|
+
NexxtInfoBlock: T,
|
|
14
|
+
NexxtProgressBar: C,
|
|
15
|
+
NexxtSocialIcons: S,
|
|
16
|
+
NexxtSocialMediaCustomTemplate: b,
|
|
17
|
+
NexxtSocialMediaTemplate: f,
|
|
18
|
+
NexxtSocialMediaType: h,
|
|
19
|
+
NexxtStepperHeader: k,
|
|
20
|
+
NexxtTable: B,
|
|
18
21
|
NexxtTimelineEvent: M,
|
|
19
22
|
NexxtTimelinePhaseblock: P,
|
|
20
|
-
NexxtTooltip:
|
|
21
|
-
},
|
|
23
|
+
NexxtTooltip: I
|
|
24
|
+
}, H = c({
|
|
22
25
|
meta: {
|
|
23
26
|
name: "@nexxtmove/ui",
|
|
24
27
|
configKey: "nexxtmoveUi",
|
|
@@ -30,8 +33,8 @@ const a = "components/AnimatedNumber/AnimatedNumber.vue", m = "components/Button
|
|
|
30
33
|
addCSS: !0
|
|
31
34
|
},
|
|
32
35
|
setup(e, o) {
|
|
33
|
-
for (const [t, n] of Object.entries(
|
|
34
|
-
|
|
36
|
+
for (const [t, n] of Object.entries(y))
|
|
37
|
+
s({
|
|
35
38
|
name: t,
|
|
36
39
|
export: "default",
|
|
37
40
|
filePath: `@nexxtmove/ui/${n}`
|
|
@@ -49,5 +52,5 @@ const a = "components/AnimatedNumber/AnimatedNumber.vue", m = "components/Button
|
|
|
49
52
|
}
|
|
50
53
|
});
|
|
51
54
|
export {
|
|
52
|
-
|
|
55
|
+
H as default
|
|
53
56
|
};
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import
|
|
2
|
+
import NexxtIcon from '../Icon/Icon.vue'
|
|
3
3
|
|
|
4
4
|
interface NexxtButtonProps {
|
|
5
5
|
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'link'
|
|
6
|
-
icon?: InstanceType<typeof
|
|
6
|
+
icon?: InstanceType<typeof NexxtIcon>['name']
|
|
7
7
|
iconRight?: boolean
|
|
8
8
|
disabled?: boolean
|
|
9
9
|
loading?: boolean
|
|
@@ -34,7 +34,7 @@ const {
|
|
|
34
34
|
]"
|
|
35
35
|
:data-variant="variant"
|
|
36
36
|
>
|
|
37
|
-
<
|
|
37
|
+
<NexxtIcon
|
|
38
38
|
v-if="icon"
|
|
39
39
|
:name="icon"
|
|
40
40
|
class="transition-all duration-200"
|
|
@@ -60,7 +60,7 @@ const {
|
|
|
60
60
|
leave-to-class="opacity-0"
|
|
61
61
|
>
|
|
62
62
|
<div v-if="loading" :class="['absolute inset-0 flex items-center justify-center rounded-lg']">
|
|
63
|
-
<
|
|
63
|
+
<NexxtIcon name="spinner-third" type="light" class="fa-spin absolute text-sm" />
|
|
64
64
|
</div>
|
|
65
65
|
</Transition>
|
|
66
66
|
</button>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
|
2
|
+
import Checkbox from './Checkbox.vue'
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
title: 'Components/Atoms/Checkbox',
|
|
6
|
+
component: Checkbox,
|
|
7
|
+
} satisfies Meta<typeof Checkbox>
|
|
8
|
+
|
|
9
|
+
type Story = StoryObj<typeof Checkbox>
|
|
10
|
+
|
|
11
|
+
export const Default: Story = {
|
|
12
|
+
args: {
|
|
13
|
+
modelValue: false,
|
|
14
|
+
},
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const Checked: Story = {
|
|
18
|
+
args: {
|
|
19
|
+
modelValue: true,
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const Disabled: Story = {
|
|
24
|
+
args: {
|
|
25
|
+
modelValue: false,
|
|
26
|
+
disabled: true,
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const DisabledChecked: Story = {
|
|
31
|
+
args: {
|
|
32
|
+
modelValue: true,
|
|
33
|
+
disabled: true,
|
|
34
|
+
},
|
|
35
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import Checkbox from './Checkbox.vue'
|
|
4
|
+
|
|
5
|
+
describe('Checkbox', () => {
|
|
6
|
+
it('renders a checkbox input', () => {
|
|
7
|
+
const wrapper = mount(Checkbox)
|
|
8
|
+
|
|
9
|
+
expect(wrapper.find('input[type="checkbox"]').exists()).toBe(true)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('is unchecked by default', () => {
|
|
13
|
+
const wrapper = mount(Checkbox)
|
|
14
|
+
|
|
15
|
+
expect((wrapper.find('input').element as HTMLInputElement).checked).toBe(false)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('is checked when modelValue is true', () => {
|
|
19
|
+
const wrapper = mount(Checkbox, { props: { modelValue: true } })
|
|
20
|
+
|
|
21
|
+
expect((wrapper.find('input').element as HTMLInputElement).checked).toBe(true)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('emits update:modelValue when toggled', async () => {
|
|
25
|
+
const wrapper = mount(Checkbox, { props: { modelValue: false } })
|
|
26
|
+
|
|
27
|
+
await wrapper.find('input').setValue(true)
|
|
28
|
+
|
|
29
|
+
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('disables the input when disabled prop is true', () => {
|
|
33
|
+
const wrapper = mount(Checkbox, { props: { disabled: true } })
|
|
34
|
+
|
|
35
|
+
expect((wrapper.find('input').element as HTMLInputElement).disabled).toBe(true)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('applies opacity class when disabled', () => {
|
|
39
|
+
const wrapper = mount(Checkbox, { props: { disabled: true } })
|
|
40
|
+
|
|
41
|
+
expect(wrapper.find('label').classes()).toContain('opacity-50')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('forwards attributes to the input', () => {
|
|
45
|
+
const wrapper = mount(Checkbox, { attrs: { 'aria-label': 'Accept terms' } })
|
|
46
|
+
|
|
47
|
+
expect(wrapper.find('input').attributes('aria-label')).toBe('Accept terms')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('renders the check icon', () => {
|
|
51
|
+
const wrapper = mount(Checkbox)
|
|
52
|
+
|
|
53
|
+
expect(wrapper.find('i.fa-check').exists()).toBe(true)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, watchEffect } from 'vue'
|
|
3
|
+
import Icon from '../Icon/Icon.vue'
|
|
4
|
+
|
|
5
|
+
interface NexxtCheckboxProps {
|
|
6
|
+
disabled?: boolean
|
|
7
|
+
indeterminate?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
defineOptions({
|
|
11
|
+
name: 'NexxtCheckbox',
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const model = defineModel<boolean>()
|
|
15
|
+
const { indeterminate } = defineProps<NexxtCheckboxProps>()
|
|
16
|
+
|
|
17
|
+
const inputRef = ref<HTMLInputElement>()
|
|
18
|
+
watchEffect(() => {
|
|
19
|
+
if (inputRef.value) inputRef.value.indeterminate = indeterminate ?? false
|
|
20
|
+
})
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<label :class="['inline-flex cursor-pointer', disabled ? 'cursor-not-allowed opacity-50' : '']">
|
|
25
|
+
<input
|
|
26
|
+
v-bind="$attrs"
|
|
27
|
+
ref="inputRef"
|
|
28
|
+
type="checkbox"
|
|
29
|
+
class="peer sr-only"
|
|
30
|
+
v-model="model"
|
|
31
|
+
:disabled="disabled"
|
|
32
|
+
/>
|
|
33
|
+
<span
|
|
34
|
+
aria-hidden="true"
|
|
35
|
+
:class="[
|
|
36
|
+
'flex h-5 w-5 shrink-0 items-center justify-center rounded border transition-colors duration-200',
|
|
37
|
+
'peer-focus-visible:ring-primary peer-focus-visible:ring-2 peer-focus-visible:ring-offset-2',
|
|
38
|
+
model || indeterminate
|
|
39
|
+
? 'border-primary text-cornflower-blue-600'
|
|
40
|
+
: 'border-gray-300 text-transparent',
|
|
41
|
+
]"
|
|
42
|
+
>
|
|
43
|
+
<Icon :name="indeterminate ? 'minus' : 'check'" type="solid" class="text-xs" />
|
|
44
|
+
</span>
|
|
45
|
+
</label>
|
|
46
|
+
</template>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
|
2
|
+
import DropdowndButton from './DropdownButton.vue'
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
title: 'Components/Atoms/Dropdown Button',
|
|
6
|
+
component: DropdowndButton,
|
|
7
|
+
decorators: [
|
|
8
|
+
() => ({
|
|
9
|
+
template:
|
|
10
|
+
'<div style="min-height: 250px; padding: 16px; display: flex; align-items: center; justify-content: center;"><story /></div>',
|
|
11
|
+
}),
|
|
12
|
+
],
|
|
13
|
+
parameters: {
|
|
14
|
+
design: {
|
|
15
|
+
type: 'figma',
|
|
16
|
+
url: 'https://www.figma.com/design/CdbFJ7qUga6mtjagcPDvYK/Design-System?node-id=862-1453&t=1SBsWbYXUXIJUJuq-4',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
} satisfies Meta<typeof DropdowndButton>
|
|
20
|
+
|
|
21
|
+
type Story = StoryObj<typeof DropdowndButton>
|
|
22
|
+
|
|
23
|
+
export const Default: Story = {
|
|
24
|
+
args: {
|
|
25
|
+
label: 'Actions',
|
|
26
|
+
action: () => alert('Primary action clicked'),
|
|
27
|
+
options: [
|
|
28
|
+
{ label: 'Edit', action: () => alert('Edit clicked'), icon: 'pencil' },
|
|
29
|
+
{ label: 'Delete', action: () => alert('Delete clicked'), icon: 'trash-can' },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const Secondary: Story = {
|
|
35
|
+
args: {
|
|
36
|
+
label: 'More info',
|
|
37
|
+
variant: 'secondary',
|
|
38
|
+
action: () => alert('Secondary main action'),
|
|
39
|
+
options: [
|
|
40
|
+
{ label: 'View Profile', action: '/profile', icon: 'user' },
|
|
41
|
+
{ label: 'Settings', action: '/settings', icon: 'gear' },
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const LeftPlacement: Story = {
|
|
47
|
+
args: {
|
|
48
|
+
label: 'Menu',
|
|
49
|
+
placement: 'left',
|
|
50
|
+
action: () => console.log('Main action'),
|
|
51
|
+
options: [
|
|
52
|
+
{ label: 'Option 1', action: () => console.log('1') },
|
|
53
|
+
{ label: 'Option 2', action: () => console.log('2') },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const WithIcon: Story = {
|
|
59
|
+
args: {
|
|
60
|
+
label: 'Download',
|
|
61
|
+
icon: 'download',
|
|
62
|
+
action: '/download/all',
|
|
63
|
+
options: [
|
|
64
|
+
{ label: 'As PDF', action: '/download/pdf', icon: 'file-pdf' },
|
|
65
|
+
{ label: 'As CSV', action: '/download/csv', icon: 'file-csv' },
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import type { IconName } from '@awesome.me/kit-37b149f3ef/icons'
|
|
4
|
+
import DropdownButton, { type NexxtDropdownButtonProps } from './DropdownButton.vue'
|
|
5
|
+
|
|
6
|
+
const stubs = { NexxtIcon: { template: '<span :data-name="name" />', props: ['name'] } }
|
|
7
|
+
|
|
8
|
+
const defaultProps: NexxtDropdownButtonProps = {
|
|
9
|
+
label: 'Actions',
|
|
10
|
+
action: '/main-action',
|
|
11
|
+
options: [
|
|
12
|
+
{ label: 'Edit', action: '/edit', icon: 'pencil' as IconName },
|
|
13
|
+
{ label: 'Delete', action: '/delete', icon: 'trash-can' as IconName },
|
|
14
|
+
],
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const mountDropdown = (props = {}) =>
|
|
18
|
+
mount(DropdownButton, {
|
|
19
|
+
props: { ...defaultProps, ...props } as NexxtDropdownButtonProps,
|
|
20
|
+
global: { stubs },
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('DropdownButton', () => {
|
|
24
|
+
describe('rendering', () => {
|
|
25
|
+
it('renders the main label', () => {
|
|
26
|
+
const wrapper = mountDropdown()
|
|
27
|
+
expect(wrapper.text()).toContain('Actions')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('renders the main icon when provided', () => {
|
|
31
|
+
const wrapper = mountDropdown({ icon: 'download' })
|
|
32
|
+
expect(wrapper.find('[data-name="download"]').exists()).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('renders the primary variant by default', () => {
|
|
36
|
+
const wrapper = mountDropdown()
|
|
37
|
+
expect(wrapper.find('.bg-button-bg-primary-default').exists()).toBe(true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('renders the secondary variant when specified', () => {
|
|
41
|
+
const wrapper = mountDropdown({ variant: 'secondary' })
|
|
42
|
+
expect(wrapper.find('.bg-button-bg-secondary-default').exists()).toBe(true)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('does not show the dropdown menu by default', () => {
|
|
46
|
+
const wrapper = mountDropdown()
|
|
47
|
+
expect(wrapper.find('[role="menu"]').exists()).toBe(false)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('interactions', () => {
|
|
52
|
+
it('emits navigate when main button is clicked with a string action', async () => {
|
|
53
|
+
const wrapper = mountDropdown()
|
|
54
|
+
const mainButton = wrapper.findAll('button')[0]
|
|
55
|
+
await mainButton.trigger('click')
|
|
56
|
+
|
|
57
|
+
expect(wrapper.emitted('navigate')).toBeTruthy()
|
|
58
|
+
expect(wrapper.emitted('navigate')?.[0]).toEqual(['/main-action'])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('executes function when main button is clicked with function action', async () => {
|
|
62
|
+
const action = vi.fn()
|
|
63
|
+
const wrapper = mountDropdown({ action })
|
|
64
|
+
const mainButton = wrapper.findAll('button')[0]
|
|
65
|
+
await mainButton.trigger('click')
|
|
66
|
+
|
|
67
|
+
expect(action).toHaveBeenCalled()
|
|
68
|
+
expect(wrapper.emitted('navigate')).toBeFalsy()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('toggles the dropdown menu when chevron is clicked', async () => {
|
|
72
|
+
const wrapper = mountDropdown()
|
|
73
|
+
const toggleButton = wrapper.findAll('button')[1]
|
|
74
|
+
|
|
75
|
+
await toggleButton.trigger('click')
|
|
76
|
+
expect(wrapper.find('[role="menu"]').exists()).toBe(true)
|
|
77
|
+
expect(toggleButton.attributes('aria-expanded')).toBe('true')
|
|
78
|
+
|
|
79
|
+
await toggleButton.trigger('click')
|
|
80
|
+
expect(wrapper.find('[role="menu"]').exists()).toBe(false)
|
|
81
|
+
expect(toggleButton.attributes('aria-expanded')).toBe('false')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('closes the dropdown and emits navigate when an option is clicked', async () => {
|
|
85
|
+
const wrapper = mountDropdown()
|
|
86
|
+
await wrapper.findAll('button')[1].trigger('click')
|
|
87
|
+
|
|
88
|
+
const option = wrapper.find('[role="menuitem"]')
|
|
89
|
+
await option.trigger('click')
|
|
90
|
+
|
|
91
|
+
expect(wrapper.emitted('navigate')).toBeTruthy()
|
|
92
|
+
expect(wrapper.emitted('navigate')?.[0]).toEqual(['/edit'])
|
|
93
|
+
expect(wrapper.find('[role="menu"]').exists()).toBe(false)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('placement', () => {
|
|
98
|
+
it('aligns to right by default', () => {
|
|
99
|
+
const wrapper = mountDropdown()
|
|
100
|
+
expect(wrapper.find('.items-end').exists()).toBe(true)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('aligns to left when placement is set to left', () => {
|
|
104
|
+
const wrapper = mountDropdown({ placement: 'left' })
|
|
105
|
+
expect(wrapper.find('.items-start').exists()).toBe(true)
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe('accessibility', () => {
|
|
110
|
+
it('has correct ARIA roles', async () => {
|
|
111
|
+
const wrapper = mountDropdown()
|
|
112
|
+
await wrapper.findAll('button')[1].trigger('click')
|
|
113
|
+
|
|
114
|
+
expect(wrapper.find('[role="menu"]').exists()).toBe(true)
|
|
115
|
+
expect(wrapper.find('[role="menuitem"]').exists()).toBe(true)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('has aria-haspopup on the toggle button', () => {
|
|
119
|
+
const wrapper = mountDropdown()
|
|
120
|
+
const toggleButton = wrapper.findAll('button')[1]
|
|
121
|
+
expect(toggleButton.attributes('aria-haspopup')).toBe('menu')
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
})
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
|
3
|
+
import NexxtIcon from '../Icon/Icon.vue'
|
|
4
|
+
|
|
5
|
+
interface NexxtDropdownButton {
|
|
6
|
+
label: string
|
|
7
|
+
action: string | (() => void)
|
|
8
|
+
icon?: InstanceType<typeof NexxtIcon>['name']
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface NexxtDropdownButtonProps extends NexxtDropdownButton {
|
|
12
|
+
variant?: 'primary' | 'secondary'
|
|
13
|
+
options: NexxtDropdownButton[]
|
|
14
|
+
placement?: 'left' | 'right'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const {
|
|
18
|
+
variant = 'primary',
|
|
19
|
+
options,
|
|
20
|
+
label,
|
|
21
|
+
action,
|
|
22
|
+
icon,
|
|
23
|
+
placement = 'right',
|
|
24
|
+
} = defineProps<NexxtDropdownButtonProps>()
|
|
25
|
+
|
|
26
|
+
const emit = defineEmits<{
|
|
27
|
+
navigate: [url: string]
|
|
28
|
+
}>()
|
|
29
|
+
|
|
30
|
+
const isOpen = ref(false)
|
|
31
|
+
const wrapperRef = ref<HTMLElement | null>(null)
|
|
32
|
+
|
|
33
|
+
const handleAction = (action: string | (() => void)) => {
|
|
34
|
+
if (typeof action === 'function') {
|
|
35
|
+
action()
|
|
36
|
+
} else {
|
|
37
|
+
emit('navigate', action)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const toggleDropdown = () => {
|
|
42
|
+
isOpen.value = !isOpen.value
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const handleOptionClick = (optionAction: string | (() => void)) => {
|
|
46
|
+
handleAction(optionAction)
|
|
47
|
+
isOpen.value = false
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
51
|
+
if (wrapperRef.value && !wrapperRef.value.contains(event.target as Node)) {
|
|
52
|
+
isOpen.value = false
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
onMounted(() => {
|
|
57
|
+
document.addEventListener('mousedown', handleClickOutside)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
onUnmounted(() => {
|
|
61
|
+
document.removeEventListener('mousedown', handleClickOutside)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const componentClasses = computed(() => {
|
|
65
|
+
if (variant === 'primary') {
|
|
66
|
+
return 'bg-button-bg-primary-default text-button-text-primary-default'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (variant === 'secondary') {
|
|
70
|
+
return 'bg-button-bg-secondary-default text-button-text-secondary-default border-button-border-secondary-default border border-solid'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return ''
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const buttonClasses = computed(() => {
|
|
77
|
+
if (variant === 'primary') {
|
|
78
|
+
return 'bg-button-bg-primary-default hover:bg-button-bg-primary-hover focus-visible:bg-button-bg-primary-focussed focus-visible:outline-button-border-primary-focussed focus-visible:outline focus-visible:outline-solid active:bg-button-bg-primary-selected'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (variant === 'secondary') {
|
|
82
|
+
return 'bg-button-bg-secondary-default hover:bg-button-bg-secondary-hover focus-visible:bg-button-bg-secondary-focussed focus-visible:outline-button-border-secondary-focussed focus-visible:outline focus-visible:outline-solid active:bg-button-bg-secondary-selected'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return ''
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const transitionClasses = computed(() => {
|
|
89
|
+
const origin = placement === 'right' ? 'origin-top-right' : 'origin-top-left'
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
enterActiveClass: `transition-all duration-200 ease-out ${origin}`,
|
|
93
|
+
leaveActiveClass: `transition-all duration-150 ease-in ${origin}`,
|
|
94
|
+
enterFromClass: 'opacity-0 scale-95 -translate-y-3',
|
|
95
|
+
leaveToClass: 'opacity-0 scale-95 -translate-y-3',
|
|
96
|
+
enterToClass: 'opacity-100 scale-100 translate-y-0',
|
|
97
|
+
leaveFromClass: 'opacity-100 scale-100 translate-y-0',
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
</script>
|
|
101
|
+
|
|
102
|
+
<template>
|
|
103
|
+
<div
|
|
104
|
+
ref="wrapperRef"
|
|
105
|
+
class="relative inline-flex flex-col"
|
|
106
|
+
:class="placement === 'right' ? 'items-end' : 'items-start'"
|
|
107
|
+
>
|
|
108
|
+
<div class="inline-flex items-center overflow-hidden rounded-md" :class="componentClasses">
|
|
109
|
+
<button
|
|
110
|
+
class="flex cursor-pointer items-center gap-2 px-4 py-2 transition-colors duration-200"
|
|
111
|
+
:class="buttonClasses"
|
|
112
|
+
@click="handleAction(action)"
|
|
113
|
+
>
|
|
114
|
+
<NexxtIcon v-if="icon" :name="icon" aria-hidden="true" />
|
|
115
|
+
{{ label }}
|
|
116
|
+
</button>
|
|
117
|
+
<span
|
|
118
|
+
class="h-7 w-px rounded-sm"
|
|
119
|
+
:class="variant === 'primary' ? 'bg-white' : 'bg-gray-200'"
|
|
120
|
+
aria-hidden="true"
|
|
121
|
+
></span>
|
|
122
|
+
<button
|
|
123
|
+
class="cursor-pointer px-2 py-2 transition-colors duration-200"
|
|
124
|
+
:class="buttonClasses"
|
|
125
|
+
@click="toggleDropdown"
|
|
126
|
+
aria-haspopup="menu"
|
|
127
|
+
:aria-expanded="isOpen"
|
|
128
|
+
:aria-label="`Open ${label} menu`"
|
|
129
|
+
>
|
|
130
|
+
<NexxtIcon
|
|
131
|
+
name="chevron-down"
|
|
132
|
+
class="transition-transform duration-200"
|
|
133
|
+
:class="{ 'rotate-180': isOpen }"
|
|
134
|
+
aria-hidden="true"
|
|
135
|
+
/>
|
|
136
|
+
</button>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<Transition v-bind="transitionClasses">
|
|
140
|
+
<div
|
|
141
|
+
v-if="isOpen"
|
|
142
|
+
role="menu"
|
|
143
|
+
class="absolute top-full z-10 mt-2.5 min-w-full rounded-md border border-gray-200 bg-white"
|
|
144
|
+
:class="placement === 'right' ? 'right-0' : 'left-0'"
|
|
145
|
+
>
|
|
146
|
+
<button
|
|
147
|
+
v-for="option in options"
|
|
148
|
+
:key="option.label"
|
|
149
|
+
role="menuitem"
|
|
150
|
+
class="flex w-full min-w-42 cursor-pointer items-center gap-2 px-4 py-2 text-left text-sm text-gray-700 transition-colors duration-200 hover:bg-gray-100 hover:text-purple-600 focus:bg-gray-100 focus:outline-none"
|
|
151
|
+
@click="handleOptionClick(option.action)"
|
|
152
|
+
>
|
|
153
|
+
<NexxtIcon v-if="option.icon" :name="option.icon" aria-hidden="true" />
|
|
154
|
+
{{ option.label }}
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
</Transition>
|
|
158
|
+
</div>
|
|
159
|
+
</template>
|