@nexxtmove/ui 0.1.28 → 0.1.29

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/nuxt.js CHANGED
@@ -1,24 +1,25 @@
1
1
  import { defineNuxtModule as s, addComponent as c, 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/Chip/Chip.vue", p = "components/CustomerJourneyTile/CustomerJourneyTile.vue", l = "components/DatePicker/DatePicker.vue", r = "components/Header/Header.vue", d = "components/Icon/Icon.vue", v = "components/InfoBlock/InfoBlock.vue", N = "components/ProgressBar/ProgressBar.vue", T = "components/SocialIcons/SocialIcons.vue", S = "components/SocialMediaCustomTemplate/SocialMediaCustomTemplate.vue", C = "components/SocialMediaTemplate/SocialMediaTemplate.vue", f = "components/SocialMediaType/SocialMediaType.vue", h = "components/StepperHeader/StepperHeader.vue", M = "components/TimelineEvent/TimelineEvent.vue", P = "components/TimelinePhaseblock/TimelinePhaseblock.vue", b = "components/Tooltip/Tooltip.vue", k = {
2
+ const a = "components/AnimatedNumber/AnimatedNumber.vue", m = "components/Button/Button.vue", u = "components/Calendar/Calendar.vue", p = "components/Chip/Chip.vue", x = "components/CustomerJourneyTile/CustomerJourneyTile.vue", l = "components/DatePicker/DatePicker.vue", r = "components/DropdownButton/DropdownButton.vue", d = "components/Header/Header.vue", v = "components/Icon/Icon.vue", N = "components/InfoBlock/InfoBlock.vue", T = "components/ProgressBar/ProgressBar.vue", S = "components/SocialIcons/SocialIcons.vue", C = "components/SocialMediaCustomTemplate/SocialMediaCustomTemplate.vue", f = "components/SocialMediaTemplate/SocialMediaTemplate.vue", B = "components/SocialMediaType/SocialMediaType.vue", h = "components/StepperHeader/StepperHeader.vue", M = "components/TimelineEvent/TimelineEvent.vue", P = "components/TimelinePhaseblock/TimelinePhaseblock.vue", b = "components/Tooltip/Tooltip.vue", k = {
3
3
  NexxtAnimatedNumber: a,
4
4
  NexxtButton: m,
5
- NexxtCalendar: x,
6
- NexxtChip: u,
7
- NexxtCustomerJourneyTile: p,
5
+ NexxtCalendar: u,
6
+ NexxtChip: p,
7
+ NexxtCustomerJourneyTile: x,
8
8
  NexxtDatePicker: l,
9
- NexxtHeader: r,
10
- NexxtIcon: d,
11
- NexxtInfoBlock: v,
12
- NexxtProgressBar: N,
13
- NexxtSocialIcons: T,
14
- NexxtSocialMediaCustomTemplate: S,
15
- NexxtSocialMediaTemplate: C,
16
- NexxtSocialMediaType: f,
9
+ NexxtDropdownButton: r,
10
+ NexxtHeader: d,
11
+ NexxtIcon: v,
12
+ NexxtInfoBlock: N,
13
+ NexxtProgressBar: T,
14
+ NexxtSocialIcons: S,
15
+ NexxtSocialMediaCustomTemplate: C,
16
+ NexxtSocialMediaTemplate: f,
17
+ NexxtSocialMediaType: B,
17
18
  NexxtStepperHeader: h,
18
19
  NexxtTimelineEvent: M,
19
20
  NexxtTimelinePhaseblock: P,
20
21
  NexxtTooltip: b
21
- }, I = s({
22
+ }, y = s({
22
23
  meta: {
23
24
  name: "@nexxtmove/ui",
24
25
  configKey: "nexxtmoveUi",
@@ -49,5 +50,5 @@ const a = "components/AnimatedNumber/AnimatedNumber.vue", m = "components/Button
49
50
  }
50
51
  });
51
52
  export {
52
- I as default
53
+ y as default
53
54
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nexxtmove/ui",
3
3
  "type": "module",
4
- "version": "0.1.28",
4
+ "version": "0.1.29",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./dist/index.d.ts",
@@ -1,9 +1,9 @@
1
1
  <script setup lang="ts">
2
- import Icon from '../Icon/Icon.vue'
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 Icon>['name']
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
- <Icon
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
- <Icon name="spinner-third" type="light" class="fa-spin absolute text-sm" />
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,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>
@@ -5,6 +5,7 @@
5
5
  "NexxtChip": "components/Chip/Chip.vue",
6
6
  "NexxtCustomerJourneyTile": "components/CustomerJourneyTile/CustomerJourneyTile.vue",
7
7
  "NexxtDatePicker": "components/DatePicker/DatePicker.vue",
8
+ "NexxtDropdownButton": "components/DropdownButton/DropdownButton.vue",
8
9
  "NexxtHeader": "components/Header/Header.vue",
9
10
  "NexxtIcon": "components/Icon/Icon.vue",
10
11
  "NexxtInfoBlock": "components/InfoBlock/InfoBlock.vue",
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export { default as NexxtCalendar } from './components/Calendar/Calendar.vue'
5
5
  export { default as NexxtChip } from './components/Chip/Chip.vue'
6
6
  export { default as NexxtCustomerJourneyTile } from './components/CustomerJourneyTile/CustomerJourneyTile.vue'
7
7
  export { default as NexxtDatePicker } from './components/DatePicker/DatePicker.vue'
8
+ export { default as NexxtDropdownButton } from './components/DropdownButton/DropdownButton.vue'
8
9
  export { default as NexxtHeader } from './components/Header/Header.vue'
9
10
  export { default as NexxtIcon } from './components/Icon/Icon.vue'
10
11
  export { default as NexxtInfoBlock } from './components/InfoBlock/InfoBlock.vue'