diff --git a/tests/unit/back-to-top.spec.js b/tests/unit/back-to-top.spec.js
new file mode 100644
index 0000000..1fb1382
--- /dev/null
+++ b/tests/unit/back-to-top.spec.js
@@ -0,0 +1,162 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import { mount } from '@vue/test-utils'
+import { expect } from 'chai'
+import { nextTick } from 'vue'
+import BackToTop from '../../src/components/back-to-top.vue'
+import { BACK_TO_TOP_COLOR_DEFAULT, getThemeProperty } from '../../src/theme.js'
+
+// Mock ChevronUpIcon
+jest.mock('@heroicons/vue/24/outline', () => ({
+ ChevronUpIcon: {
+ name: 'ChevronUpIcon',
+ template: '
chevron-up
'
+ }
+}))
+
+// Mock window.scrollTo
+const mockScrollTo = jest.fn()
+Object.defineProperty(window, 'scrollTo', {
+ value: mockScrollTo,
+ writable: true
+})
+
+describe('BackToTop.vue', () => {
+ let wrapper
+
+ beforeEach(() => {
+ // Reset scroll properties
+ Object.defineProperty(window, 'pageYOffset', { value: 0, writable: true })
+ Object.defineProperty(document.documentElement, 'scrollTop', { value: 0, writable: true })
+ Object.defineProperty(document.body, 'scrollTop', { value: 0, writable: true })
+ mockScrollTo.mockClear()
+ })
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.unmount()
+ }
+ })
+
+ describe('props', () => {
+ it('uses default offset of 300', () => {
+ wrapper = mount(BackToTop)
+ expect(wrapper.vm.offset).to.equal(300)
+ })
+
+ it('accepts custom offset prop', () => {
+ wrapper = mount(BackToTop, {
+ props: { offset: 500 }
+ })
+ expect(wrapper.vm.offset).to.equal(500)
+ })
+ })
+
+ describe('computed properties', () => {
+ it('applies correct CSS classes to button', () => {
+ wrapper = mount(BackToTop)
+ const expectedClass = `dsq-back-to-top bg-inherit -m-2 ${getThemeProperty(BACK_TO_TOP_COLOR_DEFAULT).value}`
+ expect(wrapper.vm.buttonClazz).to.equal(expectedClass)
+ })
+
+ it('shows button when scroll exceeds offset', async () => {
+ wrapper = mount(BackToTop, {
+ props: { offset: 200 }
+ })
+
+ // Initially hidden
+ expect(wrapper.vm.show).to.be.false
+
+ // Simulate scroll
+ Object.defineProperty(window, 'pageYOffset', { value: 250, writable: true })
+ wrapper.vm.scrollTop = 250
+ await nextTick()
+
+ expect(wrapper.vm.show).to.be.true
+ })
+
+ it('hides button when scroll is below offset', async () => {
+ wrapper = mount(BackToTop, {
+ props: { offset: 300 }
+ })
+
+ // Set scroll below offset
+ Object.defineProperty(window, 'pageYOffset', { value: 250, writable: true })
+ wrapper.vm.scrollTop = 250
+ await nextTick()
+
+ expect(wrapper.vm.show).to.be.false
+ })
+ })
+
+ describe('methods', () => {
+ it('scrolls to top when button clicked', async () => {
+ // Set scroll to show button
+ Object.defineProperty(window, 'pageYOffset', { value: 400, writable: true })
+
+ wrapper = mount(BackToTop)
+ wrapper.vm.scrollTop = 400
+ await nextTick()
+
+ const button = wrapper.find('button')
+ await button.trigger('click')
+
+ // Check that scrollTo was called
+ expect(mockScrollTo.mock.calls.length).to.equal(1)
+ expect(mockScrollTo.mock.calls[0][0]).to.deep.equal({ top: 0, behavior: 'smooth' })
+ })
+
+ it('gets scroll position from pageYOffset', () => {
+ Object.defineProperty(window, 'pageYOffset', { value: 100, writable: true })
+ wrapper = mount(BackToTop)
+
+ const scrollTop = wrapper.vm.getScrollTop()
+ expect(scrollTop).to.equal(100)
+ })
+
+ it('gets scroll position from documentElement.scrollTop when pageYOffset is 0', () => {
+ Object.defineProperty(window, 'pageYOffset', { value: 0, writable: true })
+ Object.defineProperty(document.documentElement, 'scrollTop', { value: 150, writable: true })
+ wrapper = mount(BackToTop)
+
+ const scrollTop = wrapper.vm.getScrollTop()
+ expect(scrollTop).to.equal(150)
+ })
+
+ it('gets scroll position from body.scrollTop as fallback', () => {
+ Object.defineProperty(window, 'pageYOffset', { value: 0, writable: true })
+ Object.defineProperty(document.documentElement, 'scrollTop', { value: 0, writable: true })
+ Object.defineProperty(document.body, 'scrollTop', { value: 200, writable: true })
+ wrapper = mount(BackToTop)
+
+ const scrollTop = wrapper.vm.getScrollTop()
+ expect(scrollTop).to.equal(200)
+ })
+ })
+
+ describe('rendering', () => {
+ it('renders button with ChevronUpIcon when shown', async () => {
+ Object.defineProperty(window, 'pageYOffset', { value: 400, writable: true })
+ wrapper = mount(BackToTop)
+ wrapper.vm.scrollTop = 400
+ await nextTick()
+
+ const button = wrapper.find('button')
+ expect(button.exists()).to.be.true
+ expect(button.classes()).to.include('dsq-back-to-top')
+ expect(button.classes()).to.include('bg-inherit')
+ expect(button.classes()).to.include('-m-2')
+ })
+
+ it('does not render button when hidden', async () => {
+ wrapper = mount(BackToTop)
+ wrapper.vm.scrollTop = 100 // Below default offset of 300
+ await nextTick()
+
+ const button = wrapper.find('button')
+ expect(button.exists()).to.be.false
+ })
+ })
+})
\ No newline at end of file
diff --git a/tests/unit/banner.spec.js b/tests/unit/banner.spec.js
new file mode 100644
index 0000000..e96c858
--- /dev/null
+++ b/tests/unit/banner.spec.js
@@ -0,0 +1,284 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import { mount } from '@vue/test-utils'
+import { expect } from 'chai'
+import { nextTick } from 'vue'
+import Banner from '../../src/components/banner.vue'
+
+describe('Banner.vue', () => {
+ let wrapper
+
+ beforeEach(() => {
+ // Create a target element for teleport
+ const targetElement = document.createElement('div')
+ targetElement.id = 'banner-container'
+ document.body.appendChild(targetElement)
+ })
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.unmount()
+ }
+ // Clean up target element
+ const targetElement = document.getElementById('banner-container')
+ if (targetElement) {
+ document.body.removeChild(targetElement)
+ }
+ })
+
+ describe('props', () => {
+ it('has default show prop as false', () => {
+ wrapper = mount(Banner, {
+ props: { parent: 'body' }
+ })
+ expect(wrapper.vm.show).to.be.false
+ })
+
+ it('accepts show prop', () => {
+ wrapper = mount(Banner, {
+ props: { show: true, parent: 'body' }
+ })
+ expect(wrapper.vm.show).to.be.true
+ })
+
+ it('requires parent prop', () => {
+ const parent = '#banner-container'
+ wrapper = mount(Banner, {
+ props: { parent }
+ })
+ expect(wrapper.vm.parent).to.equal(parent)
+ })
+
+ it('has default bottom prop as false', () => {
+ wrapper = mount(Banner, {
+ props: { parent: 'body' }
+ })
+ expect(wrapper.vm.bottom).to.be.false
+ })
+
+ it('accepts bottom prop', () => {
+ wrapper = mount(Banner, {
+ props: { parent: 'body', bottom: true }
+ })
+ expect(wrapper.vm.bottom).to.be.true
+ })
+
+ it('has default closeButtonTitle', () => {
+ wrapper = mount(Banner, {
+ props: { parent: 'body' }
+ })
+ expect(wrapper.vm.closeButtonTitle).to.equal('Close')
+ })
+
+ it('accepts custom closeButtonTitle', () => {
+ const customTitle = 'Dismiss Banner'
+ wrapper = mount(Banner, {
+ props: { parent: 'body', closeButtonTitle: customTitle }
+ })
+ expect(wrapper.vm.closeButtonTitle).to.equal(customTitle)
+ })
+ })
+
+ describe('computed properties', () => {
+ it('hides banner when show is false', () => {
+ wrapper = mount(Banner, {
+ props: { show: false, parent: 'body' }
+ })
+ expect(wrapper.vm.showBanner).to.be.false
+ })
+
+ it('shows banner when show is true and not force closed', () => {
+ wrapper = mount(Banner, {
+ props: { show: true, parent: 'body' }
+ })
+ expect(wrapper.vm.showBanner).to.be.true
+ })
+
+ it('hides banner when force closed', async () => {
+ wrapper = mount(Banner, {
+ props: { show: true, parent: 'body' }
+ })
+
+ wrapper.vm.forceCloseBanner = true
+ await nextTick()
+
+ expect(wrapper.vm.showBanner).to.be.false
+ })
+
+ it('applies correct CSS classes for top positioning (default)', () => {
+ wrapper = mount(Banner, {
+ props: { parent: 'body' }
+ })
+
+ const classes = wrapper.vm.clazz
+ expect(classes).to.include('top-0')
+ expect(classes).to.include('fixed')
+ expect(classes).to.include('z-50')
+ expect(classes).to.include('pl-3 py-3 pr-12 w-full bg-lime-300 flex items-center justify-center font-medium shadow-sm')
+ })
+
+ it('applies correct CSS classes for bottom positioning', () => {
+ wrapper = mount(Banner, {
+ props: { parent: 'body', bottom: true }
+ })
+
+ const classes = wrapper.vm.clazz
+ expect(classes).to.include('bottom-0')
+ expect(classes).to.include('fixed')
+ expect(classes).to.include('z-50')
+ })
+
+ it('applies absolute positioning when no parent specified', () => {
+ // Test with disabled teleport
+ wrapper = mount(Banner, {
+ props: { parent: '' },
+ global: {
+ stubs: {
+ Teleport: {
+ template: '
'
+ }
+ }
+ }
+ })
+
+ const classes = wrapper.vm.clazz
+ expect(classes).to.include('absolute')
+ expect(classes).to.include('z-10')
+ })
+
+ it('applies fixed positioning when parent specified', () => {
+ wrapper = mount(Banner, {
+ props: { parent: 'body' }
+ })
+
+ const classes = wrapper.vm.clazz
+ expect(classes).to.include('fixed')
+ expect(classes).to.include('z-50')
+ })
+ })
+
+ describe('methods and events', () => {
+ it('closes banner when close button clicked', async () => {
+ wrapper = mount(Banner, {
+ props: { show: true, parent: 'body' }
+ })
+
+ // Initially shown
+ expect(wrapper.vm.showBanner).to.be.true
+
+ // Click close button
+ wrapper.vm.closeBanner()
+ await nextTick()
+
+ // Should be hidden
+ expect(wrapper.vm.showBanner).to.be.false
+ })
+
+ it('emits open event when banner becomes visible', async () => {
+ wrapper = mount(Banner, {
+ props: { show: false, parent: 'body' }
+ })
+
+ // Change show prop to true
+ await wrapper.setProps({ show: true })
+ await nextTick()
+
+ expect(wrapper.emitted('open')).to.have.lengthOf(1)
+ })
+
+ it('emits close event when banner becomes hidden', async () => {
+ wrapper = mount(Banner, {
+ props: { show: true, parent: 'body' }
+ })
+
+ // Force close the banner
+ wrapper.vm.closeBanner()
+ await nextTick()
+
+ expect(wrapper.emitted('close')).to.have.lengthOf(1)
+ })
+
+ it('emits close event when show prop changes to false', async () => {
+ wrapper = mount(Banner, {
+ props: { show: true, parent: 'body' }
+ })
+
+ await wrapper.setProps({ show: false })
+ await nextTick()
+
+ expect(wrapper.emitted('close')).to.have.lengthOf(1)
+ })
+ })
+
+ describe('rendering', () => {
+ it('renders banner with correct classes when shown', async () => {
+ wrapper = mount(Banner, {
+ props: { show: true, parent: '#banner-container' }
+ })
+
+ // Check the content is teleported to the target
+ const targetElement = document.getElementById('banner-container')
+ expect(targetElement.children.length).to.be.greaterThan(0)
+
+ // Check the banner element in the target
+ const banner = targetElement.querySelector('.dsq-banner')
+ expect(banner).to.not.be.null
+ expect(banner.classList.contains('bg-lime-300')).to.be.true
+ expect(banner.classList.contains('fixed')).to.be.true
+ expect(banner.classList.contains('top-0')).to.be.true
+ })
+
+ it('does not render banner when hidden', () => {
+ wrapper = mount(Banner, {
+ props: { show: false, parent: '#banner-container' }
+ })
+
+ const targetElement = document.getElementById('banner-container')
+ expect(targetElement.children.length).to.equal(0)
+ })
+
+ it('renders close button with correct title', async () => {
+ const customTitle = 'Hide Banner'
+ wrapper = mount(Banner, {
+ props: { show: true, parent: '#banner-container', closeButtonTitle: customTitle }
+ })
+
+ const targetElement = document.getElementById('banner-container')
+ const closeButton = targetElement.querySelector('button')
+ expect(closeButton).to.not.be.null
+
+ const title = closeButton.querySelector('title')
+ expect(title.textContent).to.equal(customTitle)
+ })
+
+ it('renders slot content', async () => {
+ const slotContent = 'Banner message content
'
+ wrapper = mount(Banner, {
+ props: { show: true, parent: '#banner-container' },
+ slots: {
+ default: slotContent
+ }
+ })
+
+ const targetElement = document.getElementById('banner-container')
+ expect(targetElement.innerHTML).to.include('Banner message content')
+ })
+
+ it('triggers close when button clicked', async () => {
+ wrapper = mount(Banner, {
+ props: { show: true, parent: '#banner-container' }
+ })
+
+ const targetElement = document.getElementById('banner-container')
+ const closeButton = targetElement.querySelector('button')
+
+ // Manually trigger the closeBanner method instead of DOM event
+ wrapper.vm.closeBanner()
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.vm.showBanner).to.be.false
+ })
+ })
+})
\ No newline at end of file
diff --git a/tests/unit/drop-down-menu-item.spec.js b/tests/unit/drop-down-menu-item.spec.js
new file mode 100644
index 0000000..6d8dfa5
--- /dev/null
+++ b/tests/unit/drop-down-menu-item.spec.js
@@ -0,0 +1,177 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import { mount } from '@vue/test-utils'
+import { expect } from 'chai'
+import DropDownMenuItem from '../../src/components/drop-down-menu-item.vue'
+
+// Mock vue-router
+const mockPush = jest.fn()
+jest.mock('vue-router', () => ({
+ useRouter: () => ({
+ push: mockPush,
+ }),
+}))
+
+describe('DropDownMenuItem.vue', () => {
+ let wrapper
+
+ beforeEach(() => {
+ mockPush.mockClear()
+ })
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.unmount()
+ }
+ })
+
+ describe('props', () => {
+ it('has default show prop as true', () => {
+ wrapper = mount(DropDownMenuItem, {
+ props: { label: 'Test Item', href: '/test' }
+ })
+ expect(wrapper.vm.show).to.be.true
+ })
+
+ it('accepts show prop', () => {
+ wrapper = mount(DropDownMenuItem, {
+ props: { show: false, label: 'Test Item', href: '/test' }
+ })
+ expect(wrapper.vm.show).to.be.false
+ })
+
+ it('requires label prop', () => {
+ const label = 'Navigation Item'
+ wrapper = mount(DropDownMenuItem, {
+ props: { label, href: '/test' }
+ })
+ expect(wrapper.vm.label).to.equal(label)
+ })
+
+ it('requires href prop', () => {
+ const href = '/navigation-path'
+ wrapper = mount(DropDownMenuItem, {
+ props: { label: 'Test', href }
+ })
+ expect(wrapper.vm.href).to.equal(href)
+ })
+ })
+
+ describe('rendering', () => {
+ it('renders li element when show is true', () => {
+ wrapper = mount(DropDownMenuItem, {
+ props: { label: 'Test Item', href: '/test' }
+ })
+
+ const li = wrapper.find('li')
+ expect(li.exists()).to.be.true
+ expect(li.classes()).to.include('dsq-drop-down-menu-item')
+ expect(li.classes()).to.include('flex')
+ expect(li.classes()).to.include('flex-row')
+ expect(li.classes()).to.include('items-center')
+ expect(li.classes()).to.include('hover:bg-gray-300')
+ expect(li.classes()).to.include('cursor-pointer')
+ })
+
+ it('does not render li element when show is false', () => {
+ wrapper = mount(DropDownMenuItem, {
+ props: { show: false, label: 'Test Item', href: '/test' }
+ })
+
+ const li = wrapper.find('li')
+ expect(li.exists()).to.be.false
+ })
+
+ it('renders label text', () => {
+ const label = 'My Menu Item'
+ wrapper = mount(DropDownMenuItem, {
+ props: { label, href: '/test' }
+ })
+
+ expect(wrapper.text()).to.include(label)
+ const span = wrapper.find('span')
+ expect(span.text()).to.equal(label)
+ })
+
+ it('renders slot content', () => {
+ const slotContent = ''
+ wrapper = mount(DropDownMenuItem, {
+ props: { label: 'Test', href: '/test' },
+ slots: {
+ default: slotContent
+ }
+ })
+
+ expect(wrapper.html()).to.include('icon')
+ })
+
+ it('applies correct CSS classes to icon container', () => {
+ wrapper = mount(DropDownMenuItem, {
+ props: { label: 'Test', href: '/test' }
+ })
+
+ const iconContainer = wrapper.find('div.flex.items-center')
+ expect(iconContainer.exists()).to.be.true
+ expect(iconContainer.classes()).to.include('text-gray-500')
+ expect(iconContainer.classes()).to.include('group-hover:text-gray-900')
+ })
+ })
+
+ describe('behavior', () => {
+ it('navigates to href when clicked', async () => {
+ const href = '/dashboard'
+ wrapper = mount(DropDownMenuItem, {
+ props: { label: 'Dashboard', href }
+ })
+
+ const li = wrapper.find('li')
+ await li.trigger('click')
+
+ expect(mockPush.mock.calls.length).to.equal(1)
+ expect(mockPush.mock.calls[0][0]).to.deep.equal({ path: href })
+ })
+
+ it('prevents default click behavior', async () => {
+ wrapper = mount(DropDownMenuItem, {
+ props: { label: 'Test', href: '/test' }
+ })
+
+ const li = wrapper.find('li')
+ const clickEvent = { preventDefault: jest.fn() }
+
+ // Simulate click with prevent modifier
+ await li.trigger('click', clickEvent)
+
+ // The onClick method should be called (which will use router.push)
+ expect(mockPush.mock.calls.length).to.equal(1)
+ })
+
+ it('handles empty href gracefully', async () => {
+ wrapper = mount(DropDownMenuItem, {
+ props: { label: 'Test', href: '' }
+ })
+
+ const li = wrapper.find('li')
+ await li.trigger('click')
+
+ // Should not navigate if href is empty
+ expect(mockPush.mock.calls.length).to.equal(0)
+ })
+
+ it('calls onClick method when li is clicked', async () => {
+ const href = '/profile'
+ wrapper = mount(DropDownMenuItem, {
+ props: { label: 'Profile', href }
+ })
+
+ const li = wrapper.find('li')
+ await li.trigger('click')
+
+ // Check that router.push was called with correct path
+ expect(mockPush.mock.calls.length).to.equal(1)
+ expect(mockPush.mock.calls[0][0]).to.deep.equal({ path: href })
+ })
+ })
+})
\ No newline at end of file
diff --git a/tests/unit/drop-down-menu.spec.js b/tests/unit/drop-down-menu.spec.js
new file mode 100644
index 0000000..3854994
--- /dev/null
+++ b/tests/unit/drop-down-menu.spec.js
@@ -0,0 +1,242 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import { mount } from '@vue/test-utils'
+import { expect } from 'chai'
+import { nextTick } from 'vue'
+import DropDownMenu from '../../src/components/drop-down-menu.vue'
+
+// Mock throttle utility
+jest.mock('../../src/utils/throttle.js', () => ({
+ createThrottleFn: () => (fn, delay) => {
+ const throttledFn = jest.fn((...args) => fn(...args))
+ throttledFn.clear = jest.fn()
+ return throttledFn
+ }
+}))
+
+// Mock getBoundingClientRect
+const mockGetBoundingClientRect = jest.fn(() => ({
+ top: 100,
+ left: 50,
+ bottom: 200,
+ right: 200,
+ width: 150,
+ height: 100
+}))
+
+Object.defineProperty(Element.prototype, 'getBoundingClientRect', {
+ value: mockGetBoundingClientRect
+})
+
+describe('DropDownMenu.vue', () => {
+ let wrapper
+
+ beforeEach(() => {
+ // Mock window dimensions
+ Object.defineProperty(window, 'innerHeight', { value: 800, writable: true })
+ Object.defineProperty(window, 'innerWidth', { value: 1200, writable: true })
+
+ // Mock document dimensions
+ Object.defineProperty(document.documentElement, 'clientHeight', { value: 800, writable: true })
+ Object.defineProperty(document.documentElement, 'clientWidth', { value: 1200, writable: true })
+
+ mockGetBoundingClientRect.mockClear()
+ })
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.unmount()
+ }
+ })
+
+ describe('props', () => {
+ it('has default show prop as true', () => {
+ wrapper = mount(DropDownMenu)
+ expect(wrapper.vm.show).to.be.true
+ })
+
+ it('accepts show prop', () => {
+ wrapper = mount(DropDownMenu, {
+ props: { show: false }
+ })
+ expect(wrapper.vm.show).to.be.false
+ })
+
+ it('has default calculatePositionDynamically prop as true', () => {
+ wrapper = mount(DropDownMenu)
+ expect(wrapper.vm.calculatePositionDynamically).to.be.true
+ })
+
+ it('accepts calculatePositionDynamically prop', () => {
+ wrapper = mount(DropDownMenu, {
+ props: { calculatePositionDynamically: false }
+ })
+ expect(wrapper.vm.calculatePositionDynamically).to.be.false
+ })
+ })
+
+ describe('rendering', () => {
+ it('renders menu when show is true', () => {
+ wrapper = mount(DropDownMenu, {
+ props: { show: true }
+ })
+
+ const menu = wrapper.find('.dsq-drop-down-menu')
+ expect(menu.exists()).to.be.true
+ expect(menu.classes()).to.include('bg-gray-50')
+ expect(menu.classes()).to.include('text-gray-900')
+ expect(menu.classes()).to.include('fixed')
+ expect(menu.classes()).to.include('shadow-md')
+ expect(menu.classes()).to.include('rounded-md')
+ })
+
+ it('does not render menu when show is false', () => {
+ wrapper = mount(DropDownMenu, {
+ props: { show: false }
+ })
+
+ const menu = wrapper.find('.dsq-drop-down-menu')
+ expect(menu.exists()).to.be.false
+ })
+
+ it('renders ul with correct classes', () => {
+ wrapper = mount(DropDownMenu, {
+ props: { show: true }
+ })
+
+ const ul = wrapper.find('ul')
+ expect(ul.exists()).to.be.true
+ expect(ul.classes()).to.include('dsq-drop-down-menu')
+ expect(ul.classes()).to.include('text-left')
+ expect(ul.classes()).to.include('text-lg')
+ expect(ul.classes()).to.include('list-none')
+ })
+
+ it('renders slot content', () => {
+ const slotContent = 'Menu Item'
+ wrapper = mount(DropDownMenu, {
+ props: { show: true },
+ slots: {
+ default: slotContent
+ }
+ })
+
+ expect(wrapper.html()).to.include('Menu Item')
+ })
+ })
+
+ describe('position calculation', () => {
+ beforeEach(() => {
+ // Create a mock parent element
+ const mockParent = document.createElement('div')
+ mockParent.getBoundingClientRect = jest.fn(() => ({
+ top: 150,
+ left: 100,
+ bottom: 200,
+ right: 300,
+ width: 200,
+ height: 50
+ }))
+
+ // Setup DOM for testing
+ document.body.appendChild(mockParent)
+ })
+
+ it('calculates position when show is true and calculatePositionDynamically is enabled', async () => {
+ wrapper = mount(DropDownMenu, {
+ props: { show: true, calculatePositionDynamically: true },
+ attachTo: document.body
+ })
+
+ // Wait for the component to be mounted and calculations to run
+ await nextTick()
+
+ // The component should attempt to calculate position
+ expect(wrapper.vm.calculatePositionDynamically).to.be.true
+ expect(wrapper.vm.show).to.be.true
+ })
+
+ it('does not calculate position when calculatePositionDynamically is false', async () => {
+ wrapper = mount(DropDownMenu, {
+ props: { show: true, calculatePositionDynamically: false }
+ })
+
+ await nextTick()
+
+ // Component should not attempt dynamic positioning
+ expect(wrapper.vm.calculatePositionDynamically).to.be.false
+ })
+ })
+
+ describe('viewport detection', () => {
+ it('correctly identifies element in viewport', () => {
+ wrapper = mount(DropDownMenu)
+
+ const rect = {
+ top: 10,
+ left: 10,
+ bottom: 100,
+ right: 100
+ }
+
+ const result = wrapper.vm.isInViewport(rect)
+ expect(result).to.be.true
+ })
+
+ it('correctly identifies element outside viewport (negative top)', () => {
+ wrapper = mount(DropDownMenu)
+
+ const rect = {
+ top: -10,
+ left: 10,
+ bottom: 100,
+ right: 100
+ }
+
+ const result = wrapper.vm.isInViewport(rect)
+ expect(result).to.be.false
+ })
+
+ it('correctly identifies element outside viewport (exceeds bottom)', () => {
+ wrapper = mount(DropDownMenu)
+
+ const rect = {
+ top: 10,
+ left: 10,
+ bottom: 900, // Beyond window.innerHeight (800)
+ right: 100
+ }
+
+ const result = wrapper.vm.isInViewport(rect)
+ expect(result).to.be.false
+ })
+
+ it('correctly identifies element outside viewport (exceeds right)', () => {
+ wrapper = mount(DropDownMenu)
+
+ const rect = {
+ top: 10,
+ left: 10,
+ bottom: 100,
+ right: 1300 // Beyond window.innerWidth (1200)
+ }
+
+ const result = wrapper.vm.isInViewport(rect)
+ expect(result).to.be.false
+ })
+ })
+
+ describe('lifecycle management', () => {
+ it('clears throttle on unmount', () => {
+ wrapper = mount(DropDownMenu)
+
+ const throttleClearSpy = jest.spyOn(wrapper.vm.throttle, 'clear')
+
+ wrapper.unmount()
+
+ expect(throttleClearSpy.mock.calls.length).to.equal(1)
+ })
+ })
+})
\ No newline at end of file
diff --git a/tests/unit/form-elements-container.spec.js b/tests/unit/form-elements-container.spec.js
new file mode 100644
index 0000000..7581b16
--- /dev/null
+++ b/tests/unit/form-elements-container.spec.js
@@ -0,0 +1,117 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import { mount } from '@vue/test-utils'
+import { expect } from 'chai'
+import { nextTick } from 'vue'
+import FormElementsContainer from '../../src/components/form-elements-container.vue'
+
+describe('FormElementsContainer.vue', () => {
+ let wrapper
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.unmount()
+ }
+ })
+
+ describe('props', () => {
+ it('requires id prop', () => {
+ const id = 'test-container-id'
+ wrapper = mount(FormElementsContainer, {
+ props: { id, class: 'custom-class' }
+ })
+ expect(wrapper.attributes('id')).to.equal(id)
+ })
+
+ it('requires class prop', () => {
+ const customClass = 'custom-styling-class'
+ wrapper = mount(FormElementsContainer, {
+ props: { id: 'test-id', class: customClass }
+ })
+
+ const innerDiv = wrapper.find('div').find('div')
+ expect(innerDiv.classes()).to.include('custom-styling-class')
+ })
+ })
+
+ describe('computed properties', () => {
+ it('applies correct CSS classes combining flex layout with custom class', () => {
+ wrapper = mount(FormElementsContainer, {
+ props: { id: 'test-id', class: 'my-custom-class' }
+ })
+
+ const expectedClasses = 'flex flex-col space-y-5 my-custom-class'
+ expect(wrapper.vm.clazz).to.equal(expectedClasses)
+ })
+
+ it('handles empty custom class', () => {
+ wrapper = mount(FormElementsContainer, {
+ props: { id: 'test-id', class: '' }
+ })
+
+ const expectedClasses = 'flex flex-col space-y-5 '
+ expect(wrapper.vm.clazz).to.equal(expectedClasses)
+ })
+
+ it('handles multiple custom classes', () => {
+ wrapper = mount(FormElementsContainer, {
+ props: { id: 'test-id', class: 'class1 class2 class3' }
+ })
+
+ const expectedClasses = 'flex flex-col space-y-5 class1 class2 class3'
+ expect(wrapper.vm.clazz).to.equal(expectedClasses)
+ })
+ })
+
+ describe('rendering', () => {
+ it('renders outer div with correct id', () => {
+ const testId = 'container-wrapper-id'
+ wrapper = mount(FormElementsContainer, {
+ props: { id: testId, class: 'test-class' }
+ })
+
+ expect(wrapper.find('div').attributes('id')).to.equal(testId)
+ })
+
+ it('renders inner div with computed classes', () => {
+ wrapper = mount(FormElementsContainer, {
+ props: { id: 'test-id', class: 'additional-styles' }
+ })
+
+ const innerDiv = wrapper.find('div').find('div')
+ expect(innerDiv.classes()).to.include('flex')
+ expect(innerDiv.classes()).to.include('flex-col')
+ expect(innerDiv.classes()).to.include('space-y-5')
+ expect(innerDiv.classes()).to.include('additional-styles')
+ })
+
+ it('renders slot content', () => {
+ const slotContent = 'Form elements go here
'
+ wrapper = mount(FormElementsContainer, {
+ props: { id: 'test-id', class: 'test-class' },
+ slots: {
+ default: slotContent
+ }
+ })
+
+ expect(wrapper.html()).to.include('Form elements go here')
+ })
+
+ it('maintains structure with nested divs', () => {
+ wrapper = mount(FormElementsContainer, {
+ props: { id: 'outer-id', class: 'inner-class' }
+ })
+
+ // Should have outer div with id
+ const outerDiv = wrapper.find('div')
+ expect(outerDiv.attributes('id')).to.equal('outer-id')
+
+ // Should have inner div with classes
+ const innerDiv = outerDiv.find('div')
+ expect(innerDiv.exists()).to.be.true
+ expect(innerDiv.classes()).to.include('inner-class')
+ })
+ })
+})
\ No newline at end of file
diff --git a/tests/unit/form-immutable-text.spec.js b/tests/unit/form-immutable-text.spec.js
new file mode 100644
index 0000000..d987952
--- /dev/null
+++ b/tests/unit/form-immutable-text.spec.js
@@ -0,0 +1,184 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import { mount, shallowMount } from '@vue/test-utils'
+import { expect } from 'chai'
+import { nextTick } from 'vue'
+import FormImmutableText from '../../src/components/form-immutable-text.vue'
+import { FORM_ELEMENT_INPUT_FONT_WEIGHT_DEFAULT, FORM_ELEMENT_INPUT_TEXT_SIZE_DEFAULT, getThemeProperty } from '../../src/theme.js'
+
+// Mock heroicons
+jest.mock('@heroicons/vue/24/outline', () => ({
+ ClipboardDocumentCheckIcon: {
+ name: 'ClipboardDocumentCheckIcon',
+ template: 'check
'
+ },
+ ClipboardIcon: {
+ name: 'ClipboardIcon',
+ template: 'clipboard
'
+ }
+}))
+
+// Mock clipboard API
+const mockWriteText = jest.fn()
+Object.defineProperty(navigator, 'clipboard', {
+ value: {
+ writeText: mockWriteText
+ },
+ configurable: true
+})
+
+// Mock ClipboardItem
+Object.defineProperty(window, 'ClipboardItem', {
+ value: function() {},
+ configurable: true
+})
+
+describe('FormImmutableText.vue', () => {
+ let wrapper
+
+ beforeEach(() => {
+ mockWriteText.mockClear()
+ mockWriteText.mockResolvedValue()
+ })
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.unmount()
+ }
+ })
+
+ describe('props', () => {
+ it('has default enableCopyToClipboard prop as true', () => {
+ wrapper = shallowMount(FormImmutableText, {
+ props: { id: 'test', label: 'Label', text: 'Text' }
+ })
+ expect(wrapper.vm.enableCopyToClipboard).to.be.true
+ })
+
+ it('accepts enableCopyToClipboard prop', () => {
+ wrapper = shallowMount(FormImmutableText, {
+ props: { id: 'test', label: 'Label', text: 'Text', enableCopyToClipboard: false }
+ })
+ expect(wrapper.vm.enableCopyToClipboard).to.be.false
+ })
+
+ it('requires id prop', () => {
+ const id = 'immutable-text-field'
+ wrapper = shallowMount(FormImmutableText, {
+ props: { id, label: 'Label', text: 'Text' }
+ })
+ expect(wrapper.vm.id).to.equal(id)
+ })
+
+ it('requires label prop', () => {
+ const label = 'API Key'
+ wrapper = shallowMount(FormImmutableText, {
+ props: { id: 'test', label, text: 'Text' }
+ })
+ expect(wrapper.vm.label).to.equal(label)
+ })
+
+ it('requires text prop', () => {
+ const text = 'sk-1234567890abcdef'
+ wrapper = shallowMount(FormImmutableText, {
+ props: { id: 'test', label: 'Label', text }
+ })
+ expect(wrapper.vm.text).to.equal(text)
+ })
+ })
+
+ describe('computed properties', () => {
+ it('applies correct CSS classes to text', () => {
+ wrapper = shallowMount(FormImmutableText, {
+ props: { id: 'test', label: 'Label', text: 'Text' }
+ })
+
+ const expectedClasses = [
+ getThemeProperty(FORM_ELEMENT_INPUT_TEXT_SIZE_DEFAULT).value,
+ getThemeProperty(FORM_ELEMENT_INPUT_FONT_WEIGHT_DEFAULT).value
+ ]
+ expect(wrapper.vm.textClazz).to.deep.equal(expectedClasses)
+ })
+
+ it('shows clipboard button when conditions are met', () => {
+ wrapper = shallowMount(FormImmutableText, {
+ props: { id: 'test', label: 'Label', text: 'some-text' }
+ })
+
+ expect(wrapper.vm.showClipboardButton).to.be.true
+ })
+
+ it('hides clipboard button when text is empty', () => {
+ wrapper = shallowMount(FormImmutableText, {
+ props: { id: 'test', label: 'Label', text: '' }
+ })
+
+ expect(wrapper.vm.showClipboardButton).to.be.false
+ })
+
+ it('hides clipboard button when enableCopyToClipboard is false', () => {
+ wrapper = shallowMount(FormImmutableText, {
+ props: { id: 'test', label: 'Label', text: 'some-text', enableCopyToClipboard: false }
+ })
+
+ expect(wrapper.vm.showClipboardButton).to.be.false
+ })
+
+ it('hides clipboard button when copy was successful', async () => {
+ wrapper = shallowMount(FormImmutableText, {
+ props: { id: 'test', label: 'Label', text: 'some-text' }
+ })
+
+ wrapper.vm.copyToClipboardSuccess = true
+ await nextTick()
+
+ expect(wrapper.vm.showClipboardButton).to.be.false
+ })
+
+ it('hides clipboard button when copy failed', async () => {
+ wrapper = shallowMount(FormImmutableText, {
+ props: { id: 'test', label: 'Label', text: 'some-text' }
+ })
+
+ wrapper.vm.copyToClipboardFailure = true
+ await nextTick()
+
+ expect(wrapper.vm.showClipboardButton).to.be.false
+ })
+ })
+
+ describe('clipboard functionality', () => {
+ it('copies text to clipboard successfully', async () => {
+ const testText = 'test-api-key-12345'
+ wrapper = shallowMount(FormImmutableText, {
+ props: { id: 'test', label: 'Label', text: testText }
+ })
+
+ await wrapper.vm.copyKeyToClipboard()
+
+ expect(mockWriteText.mock.calls.length).to.equal(1)
+ expect(mockWriteText.mock.calls[0][0]).to.equal(testText)
+ expect(wrapper.vm.copyToClipboardSuccess).to.be.true
+ expect(wrapper.vm.copyToClipboardFailure).to.be.false
+ })
+
+ it('handles clipboard copy failure', async () => {
+ const testText = 'test-api-key-12345'
+ mockWriteText.mockRejectedValue(new Error('Clipboard error'))
+
+ wrapper = shallowMount(FormImmutableText, {
+ props: { id: 'test', label: 'Label', text: testText }
+ })
+
+ await wrapper.vm.copyKeyToClipboard()
+
+ expect(wrapper.vm.copyToClipboardSuccess).to.be.false
+ expect(wrapper.vm.copyToClipboardFailure).to.be.true
+ expect(wrapper.vm.copyToClipboardErrorMsg).to.equal(
+ 'Sorry, we were not able to copy to the clipboard at this time. Please copy the text manually.'
+ )
+ })
+ })
+})
\ No newline at end of file
diff --git a/tests/unit/form-input-select.spec.js b/tests/unit/form-input-select.spec.js
index b061d97..6265c9f 100644
--- a/tests/unit/form-input-select.spec.js
+++ b/tests/unit/form-input-select.spec.js
@@ -4,7 +4,9 @@
import { mount } from '@vue/test-utils';
import { expect } from 'chai';
+import { nextTick } from 'vue';
import FormInputSelect from '../../src/components/form-input-select.vue';
+import { FORM_ELEMENT_SELECT_FONT_WEIGHT_DEFAULT, FORM_ELEMENT_SELECT_TEXT_COLOR_DEFAULT, FORM_ELEMENT_SELECT_TEXT_SIZE_DEFAULT, getThemeProperty } from '../../src/theme.js';
const mockPush = jest.fn();
jest.mock('vue-router', () => ({
@@ -14,6 +16,199 @@ jest.mock('vue-router', () => ({
}));
describe('FormInputSelect.vue', () => {
+ let wrapper
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.unmount()
+ }
+ })
+
+ describe('props', () => {
+ it('requires id prop', () => {
+ const id = 'my-form-input-select'
+ wrapper = mount(FormInputSelect, {
+ props: { id, label: 'Test Label' }
+ })
+ expect(wrapper.vm.id).to.equal(id)
+ })
+
+ it('requires label prop', () => {
+ const label = 'Select Label'
+ wrapper = mount(FormInputSelect, {
+ props: { id: 'test', label }
+ })
+ expect(wrapper.vm.label).to.equal(label)
+ })
+
+ it('has default disabled prop as false', () => {
+ wrapper = mount(FormInputSelect, {
+ props: { id: 'test', label: 'Label' }
+ })
+ expect(wrapper.vm.disabled).to.be.false
+ })
+
+ it('accepts disabled prop', () => {
+ wrapper = mount(FormInputSelect, {
+ props: { id: 'test', label: 'Label', disabled: true }
+ })
+ expect(wrapper.vm.disabled).to.be.true
+ })
+
+ it('accepts description prop', () => {
+ const description = 'Helper text'
+ wrapper = mount(FormInputSelect, {
+ props: { id: 'test', label: 'Label', description }
+ })
+ expect(wrapper.vm.description).to.equal(description)
+ })
+
+ it('accepts elements prop', () => {
+ const elements = [{ id: '1', name: 'Option 1' }]
+ wrapper = mount(FormInputSelect, {
+ props: { id: 'test', label: 'Label', elements }
+ })
+ expect(wrapper.vm.elements).to.deep.equal(elements)
+ })
+
+ it('accepts modelValue prop', () => {
+ const modelValue = 1
+ wrapper = mount(FormInputSelect, {
+ props: { id: 'test', label: 'Label', modelValue }
+ })
+ expect(wrapper.vm.modelValue).to.equal(modelValue)
+ })
+
+ it('has default forceShowErrorMessage prop as false', () => {
+ wrapper = mount(FormInputSelect, {
+ props: { id: 'test', label: 'Label' }
+ })
+ expect(wrapper.vm.forceShowErrorMessage).to.be.false
+ })
+
+ it('accepts forceShowErrorMessage prop', () => {
+ wrapper = mount(FormInputSelect, {
+ props: { id: 'test', label: 'Label', forceShowErrorMessage: true }
+ })
+ expect(wrapper.vm.forceShowErrorMessage).to.be.true
+ })
+ })
+
+ describe('computed properties', () => {
+ it('calculates inputValue based on modelValue', () => {
+ wrapper = mount(FormInputSelect, {
+ props: { id: 'test', label: 'Label', modelValue: 2 }
+ })
+ expect(wrapper.vm.inputValue).to.equal(2)
+ })
+
+ it('defaults inputValue to 0 when modelValue is not set', () => {
+ wrapper = mount(FormInputSelect, {
+ props: { id: 'test', label: 'Label' }
+ })
+ expect(wrapper.vm.inputValue).to.equal(0)
+ })
+
+ it('applies correct CSS classes to select element', () => {
+ wrapper = mount(FormInputSelect, {
+ props: { id: 'test', label: 'Label' }
+ })
+
+ const expectedClass = [
+ 'dsq-form-input-select px-2 pb-1 pt-4 border-none w-full bg-inherit opacity-100 focus:outline-hidden cursor-pointer',
+ getThemeProperty(FORM_ELEMENT_SELECT_TEXT_COLOR_DEFAULT).value,
+ getThemeProperty(FORM_ELEMENT_SELECT_TEXT_SIZE_DEFAULT).value,
+ getThemeProperty(FORM_ELEMENT_SELECT_FONT_WEIGHT_DEFAULT).value
+ ].join(' ')
+
+ expect(wrapper.vm.selectClazz).to.equal(expectedClass)
+ })
+
+ it('applies correct CSS classes to option elements', () => {
+ wrapper = mount(FormInputSelect, {
+ props: { id: 'test', label: 'Label' }
+ })
+
+ const expectedClass = [
+ 'w-24',
+ getThemeProperty(FORM_ELEMENT_SELECT_TEXT_SIZE_DEFAULT).value
+ ].join(' ')
+
+ expect(wrapper.vm.optionClazz).to.equal(expectedClass)
+ })
+
+ it('validates input when elements and modelValue are present', () => {
+ const elements = [{ id: '1', name: 'Option 1' }, { id: '2', name: 'Option 2' }]
+ wrapper = mount(FormInputSelect, {
+ props: { id: 'test', label: 'Label', elements, modelValue: 1 }
+ })
+ expect(wrapper.vm.isInvalid).to.be.false
+ })
+
+ it('marks as invalid when modelValue is out of range', () => {
+ const elements = [{ id: '1', name: 'Option 1' }]
+ wrapper = mount(FormInputSelect, {
+ props: { id: 'test', label: 'Label', elements, modelValue: 5 }
+ })
+ expect(wrapper.vm.isInvalid).to.be.true
+ })
+ })
+
+ describe('methods', () => {
+ it('sets focus state on focus', async () => {
+ wrapper = mount(FormInputSelect, {
+ props: { id: 'test', label: 'Label' }
+ })
+
+ expect(wrapper.vm.isFocussed).to.be.false
+
+ wrapper.vm.onFocus()
+ await nextTick()
+
+ expect(wrapper.vm.isFocussed).to.be.true
+ })
+
+ it('clears focus state on blur', async () => {
+ wrapper = mount(FormInputSelect, {
+ props: { id: 'test', label: 'Label' }
+ })
+
+ wrapper.vm.isFocussed = true
+ wrapper.vm.onBlur()
+ await nextTick()
+
+ expect(wrapper.vm.isFocussed).to.be.false
+ })
+
+ it('emits update:modelValue on input with parsed integer', async () => {
+ wrapper = mount(FormInputSelect, {
+ props: { id: 'test', label: 'Label' }
+ })
+
+ // Mock the select element value
+ wrapper.vm.$refs.select.value = '2'
+
+ wrapper.vm.onInput()
+
+ expect(wrapper.emitted('update:modelValue')).to.have.lengthOf(1)
+ expect(wrapper.emitted('update:modelValue')[0][0]).to.equal(2)
+ })
+
+ it('focuses select element when focusSelect is called', () => {
+ wrapper = mount(FormInputSelect, {
+ props: { id: 'test', label: 'Label' }
+ })
+
+ const mockEvent = new Event('focus')
+ const dispatchSpy = jest.spyOn(wrapper.vm.$refs.select, 'dispatchEvent')
+
+ wrapper.vm.focusSelect(mockEvent)
+
+ expect(dispatchSpy.mock.calls.length).to.equal(1)
+ expect(dispatchSpy.mock.calls[0][0]).to.equal(mockEvent)
+ })
+ })
+
describe('.id', () => {
it('is set on form element container', () => {
const wrapper = mount(FormInputSelect, {
@@ -78,5 +273,124 @@ describe('FormInputSelect.vue', () => {
const option = wrapper.find('option')
expect(option.text()).to.have.length(0)
})
+
+ it('renders option with alias when name is not provided', () => {
+ const wrapper = mount(FormInputSelect, {
+ props: {
+ id: 'my-form-input-select',
+ elements: [
+ {
+ id: '1',
+ alias: 'Alternative Name'
+ }
+ ]
+ }
+ })
+ const option = wrapper.find('option')
+ expect(option.text()).to.equal('Alternative Name')
+ })
+
+ it('prefers name over alias when both are provided', () => {
+ const wrapper = mount(FormInputSelect, {
+ props: {
+ id: 'my-form-input-select',
+ elements: [
+ {
+ id: '1',
+ name: 'Primary Name',
+ alias: 'Alternative Name'
+ }
+ ]
+ }
+ })
+ const option = wrapper.find('option')
+ expect(option.text()).to.equal('Primary Name')
+ })
+ })
+
+ describe('event handling', () => {
+ it('handles focus event', async () => {
+ wrapper = mount(FormInputSelect, {
+ props: { id: 'test', label: 'Label' }
+ })
+
+ const select = wrapper.find('select')
+ await select.trigger('focus')
+
+ expect(wrapper.vm.isFocussed).to.be.true
+ })
+
+ it('handles blur event', async () => {
+ wrapper = mount(FormInputSelect, {
+ props: { id: 'test', label: 'Label' }
+ })
+
+ wrapper.vm.isFocussed = true
+ const select = wrapper.find('select')
+ await select.trigger('blur')
+
+ expect(wrapper.vm.isFocussed).to.be.false
+ })
+
+ it('handles input event and emits modelValue update', async () => {
+ const elements = [
+ { id: '1', name: 'Option 1' },
+ { id: '2', name: 'Option 2' }
+ ]
+ wrapper = mount(FormInputSelect, {
+ props: { id: 'test', label: 'Label', elements }
+ })
+
+ const select = wrapper.find('select')
+ await select.setValue('1')
+
+ expect(wrapper.emitted('update:modelValue')).to.have.lengthOf(1)
+ expect(wrapper.emitted('update:modelValue')[0][0]).to.equal(1)
+ })
+ })
+
+ describe('rendering', () => {
+ it('renders select element with correct attributes', () => {
+ const id = 'test-select'
+ wrapper = mount(FormInputSelect, {
+ props: { id, label: 'Label', disabled: true }
+ })
+
+ const select = wrapper.find('select')
+ expect(select.exists()).to.be.true
+ expect(select.attributes('id')).to.equal(id)
+ expect(select.attributes('disabled')).to.equal('')
+ })
+
+ it('renders options for each element', () => {
+ const elements = [
+ { id: '1', name: 'First Option' },
+ { id: '2', name: 'Second Option' },
+ { id: '3', name: 'Third Option' }
+ ]
+ wrapper = mount(FormInputSelect, {
+ props: { id: 'test', label: 'Label', elements }
+ })
+
+ const options = wrapper.findAll('option')
+ expect(options).to.have.lengthOf(3)
+ expect(options[0].text()).to.equal('First Option')
+ expect(options[1].text()).to.equal('Second Option')
+ expect(options[2].text()).to.equal('Third Option')
+ })
+
+ it('sets correct value attribute on options', () => {
+ const elements = [
+ { id: '1', name: 'Option 1' },
+ { id: '2', name: 'Option 2' }
+ ]
+ wrapper = mount(FormInputSelect, {
+ props: { id: 'test', label: 'Label', elements }
+ })
+
+ const options = wrapper.findAll('option')
+ expect(options[0].attributes('value')).to.equal('0')
+ expect(options[1].attributes('value')).to.equal('1')
+ })
})
})