@sabrenski/spire-ui 0.0.1

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.
Files changed (237) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +233 -0
  3. package/dist/index.d.ts +4981 -0
  4. package/dist/spire-ui.css +1 -0
  5. package/dist/spire-ui.es.js +18403 -0
  6. package/dist/spire-ui.umd.js +45 -0
  7. package/package.json +83 -0
  8. package/src/components/Accordion/Accordion.test.ts +218 -0
  9. package/src/components/Accordion/AccordionContent.vue +112 -0
  10. package/src/components/Accordion/AccordionItem.vue +87 -0
  11. package/src/components/Accordion/AccordionRoot.vue +111 -0
  12. package/src/components/Accordion/AccordionTrigger.vue +125 -0
  13. package/src/components/Accordion/index.ts +11 -0
  14. package/src/components/Accordion/keys.ts +23 -0
  15. package/src/components/Avatar/Avatar.test.ts +181 -0
  16. package/src/components/Avatar/Avatar.vue +150 -0
  17. package/src/components/Avatar/index.ts +2 -0
  18. package/src/components/Badge/Badge.test.ts +141 -0
  19. package/src/components/Badge/Badge.vue +133 -0
  20. package/src/components/Badge/index.ts +2 -0
  21. package/src/components/BadgeContainer/BadgeContainer.test.ts +150 -0
  22. package/src/components/BadgeContainer/BadgeContainer.vue +90 -0
  23. package/src/components/BadgeContainer/index.ts +2 -0
  24. package/src/components/Breadcrumb/Breadcrumb.test.ts +342 -0
  25. package/src/components/Breadcrumb/BreadcrumbEllipsis.vue +96 -0
  26. package/src/components/Breadcrumb/BreadcrumbItem.vue +16 -0
  27. package/src/components/Breadcrumb/BreadcrumbLink.vue +67 -0
  28. package/src/components/Breadcrumb/BreadcrumbList.vue +20 -0
  29. package/src/components/Breadcrumb/BreadcrumbPage.vue +25 -0
  30. package/src/components/Breadcrumb/BreadcrumbRoot.vue +41 -0
  31. package/src/components/Breadcrumb/BreadcrumbSeparator.vue +63 -0
  32. package/src/components/Breadcrumb/index.ts +13 -0
  33. package/src/components/Breadcrumb/keys.ts +7 -0
  34. package/src/components/Button/Button.test.ts +231 -0
  35. package/src/components/Button/Button.vue +349 -0
  36. package/src/components/Button/index.ts +2 -0
  37. package/src/components/Callout/Callout.test.ts +260 -0
  38. package/src/components/Callout/Callout.vue +341 -0
  39. package/src/components/Callout/index.ts +2 -0
  40. package/src/components/Card/Card.test.ts +565 -0
  41. package/src/components/Card/Card.vue +209 -0
  42. package/src/components/Card/CardContent.vue +57 -0
  43. package/src/components/Card/CardFooter.vue +72 -0
  44. package/src/components/Card/CardHeader.vue +111 -0
  45. package/src/components/Card/CardImage.vue +124 -0
  46. package/src/components/Card/index.ts +14 -0
  47. package/src/components/Chart/BarChart.vue +208 -0
  48. package/src/components/Chart/BaseChart.vue +444 -0
  49. package/src/components/Chart/Chart.test.ts +359 -0
  50. package/src/components/Chart/DonutChart.vue +283 -0
  51. package/src/components/Chart/LineChart.vue +211 -0
  52. package/src/components/Chart/index.ts +20 -0
  53. package/src/components/Chart/useChartTheme.ts +192 -0
  54. package/src/components/Checkbox/Checkbox.test.ts +209 -0
  55. package/src/components/Checkbox/Checkbox.vue +285 -0
  56. package/src/components/Checkbox/index.ts +2 -0
  57. package/src/components/ChoiceChip/ChoiceChip.test.ts +142 -0
  58. package/src/components/ChoiceChip/ChoiceChip.vue +218 -0
  59. package/src/components/ChoiceChip/index.ts +2 -0
  60. package/src/components/ChoiceChipGroup/ChoiceChipGroup.test.ts +151 -0
  61. package/src/components/ChoiceChipGroup/ChoiceChipGroup.vue +70 -0
  62. package/src/components/ChoiceChipGroup/index.ts +2 -0
  63. package/src/components/ColorPicker/ColorArea.vue +159 -0
  64. package/src/components/ColorPicker/ColorPicker.test.ts +250 -0
  65. package/src/components/ColorPicker/ColorPicker.vue +339 -0
  66. package/src/components/ColorPicker/ColorSlider.vue +191 -0
  67. package/src/components/ColorPicker/index.ts +7 -0
  68. package/src/components/Combobox/Combobox.test.ts +891 -0
  69. package/src/components/Combobox/Combobox.vue +934 -0
  70. package/src/components/Combobox/index.ts +2 -0
  71. package/src/components/DataTable/DataTable.test.ts +1221 -0
  72. package/src/components/DataTable/DataTable.vue +1415 -0
  73. package/src/components/DataTable/index.ts +10 -0
  74. package/src/components/DatePicker/DatePicker.test.ts +625 -0
  75. package/src/components/DatePicker/DatePicker.vue +1586 -0
  76. package/src/components/DatePicker/index.ts +2 -0
  77. package/src/components/Drawer/Drawer.test.ts +336 -0
  78. package/src/components/Drawer/Drawer.vue +466 -0
  79. package/src/components/Drawer/index.ts +2 -0
  80. package/src/components/Dropdown/Dropdown.test.ts +607 -0
  81. package/src/components/Dropdown/Dropdown.vue +807 -0
  82. package/src/components/Dropdown/DropdownItem.vue +227 -0
  83. package/src/components/Dropdown/DropdownSeparator.vue +14 -0
  84. package/src/components/Dropdown/DropdownSub.vue +104 -0
  85. package/src/components/Dropdown/DropdownSubContent.vue +187 -0
  86. package/src/components/Dropdown/DropdownSubTrigger.vue +151 -0
  87. package/src/components/Dropdown/index.ts +14 -0
  88. package/src/components/EmptyState/EmptyState.test.ts +180 -0
  89. package/src/components/EmptyState/EmptyState.vue +137 -0
  90. package/src/components/EmptyState/index.ts +2 -0
  91. package/src/components/FileUpload/FileUpload.test.ts +1151 -0
  92. package/src/components/FileUpload/FileUpload.vue +1042 -0
  93. package/src/components/FileUpload/index.ts +2 -0
  94. package/src/components/Heading/Heading.test.ts +107 -0
  95. package/src/components/Heading/Heading.vue +67 -0
  96. package/src/components/Heading/index.ts +2 -0
  97. package/src/components/Icon/Icon.test.ts +157 -0
  98. package/src/components/Icon/Icon.vue +86 -0
  99. package/src/components/Icon/index.ts +2 -0
  100. package/src/components/Input/Input.test.ts +273 -0
  101. package/src/components/Input/Input.vue +388 -0
  102. package/src/components/Input/index.ts +2 -0
  103. package/src/components/Layout/Container.vue +67 -0
  104. package/src/components/Layout/Grid.vue +159 -0
  105. package/src/components/Layout/GridItem.vue +154 -0
  106. package/src/components/Layout/Layout.test.ts +202 -0
  107. package/src/components/Layout/Stack.vue +128 -0
  108. package/src/components/Layout/index.ts +9 -0
  109. package/src/components/Layout/keys.ts +7 -0
  110. package/src/components/Modal/Modal.test.ts +311 -0
  111. package/src/components/Modal/Modal.vue +336 -0
  112. package/src/components/Modal/index.ts +2 -0
  113. package/src/components/Pagination/Pagination.test.ts +303 -0
  114. package/src/components/Pagination/Pagination.vue +212 -0
  115. package/src/components/Pagination/index.ts +3 -0
  116. package/src/components/Pagination/utils.ts +86 -0
  117. package/src/components/Popover/Popover.test.ts +285 -0
  118. package/src/components/Popover/Popover.vue +441 -0
  119. package/src/components/Popover/index.ts +2 -0
  120. package/src/components/Progress/Progress.test.ts +361 -0
  121. package/src/components/Progress/Progress.vue +363 -0
  122. package/src/components/Progress/index.ts +7 -0
  123. package/src/components/Radio/Radio.test.ts +216 -0
  124. package/src/components/Radio/Radio.vue +214 -0
  125. package/src/components/Radio/index.ts +2 -0
  126. package/src/components/Rating/Rating.test.ts +319 -0
  127. package/src/components/Rating/Rating.vue +247 -0
  128. package/src/components/Rating/index.ts +2 -0
  129. package/src/components/SegmentedControl/SegmentedControl.test.ts +292 -0
  130. package/src/components/SegmentedControl/SegmentedControl.vue +288 -0
  131. package/src/components/SegmentedControl/index.ts +2 -0
  132. package/src/components/Select/Select.test.ts +589 -0
  133. package/src/components/Select/Select.vue +666 -0
  134. package/src/components/Select/index.ts +2 -0
  135. package/src/components/Sidebar/Sidebar.test.ts +301 -0
  136. package/src/components/Sidebar/SidebarGroup.vue +103 -0
  137. package/src/components/Sidebar/SidebarItem.vue +196 -0
  138. package/src/components/Sidebar/SidebarLayout.vue +42 -0
  139. package/src/components/Sidebar/SidebarRoot.vue +122 -0
  140. package/src/components/Sidebar/index.ts +11 -0
  141. package/src/components/Sidebar/keys.ts +14 -0
  142. package/src/components/Skeleton/Skeleton.test.ts +130 -0
  143. package/src/components/Skeleton/Skeleton.vue +104 -0
  144. package/src/components/Skeleton/index.ts +2 -0
  145. package/src/components/Slider/Slider.test.ts +416 -0
  146. package/src/components/Slider/Slider.vue +435 -0
  147. package/src/components/Slider/index.ts +2 -0
  148. package/src/components/Slider/utils.ts +91 -0
  149. package/src/components/Spinner/Spinner.test.ts +79 -0
  150. package/src/components/Spinner/Spinner.vue +159 -0
  151. package/src/components/Spinner/index.ts +2 -0
  152. package/src/components/SpireProvider/SpireProvider.vue +71 -0
  153. package/src/components/SpireProvider/index.ts +11 -0
  154. package/src/components/Stepper/Stepper.test.ts +221 -0
  155. package/src/components/Stepper/StepperContent.vue +51 -0
  156. package/src/components/Stepper/StepperItem.vue +89 -0
  157. package/src/components/Stepper/StepperRoot.vue +101 -0
  158. package/src/components/Stepper/StepperSeparator.vue +52 -0
  159. package/src/components/Stepper/StepperTrigger.vue +144 -0
  160. package/src/components/Stepper/index.ts +11 -0
  161. package/src/components/Stepper/keys.ts +27 -0
  162. package/src/components/Switch/Switch.test.ts +214 -0
  163. package/src/components/Switch/Switch.vue +235 -0
  164. package/src/components/Switch/index.ts +2 -0
  165. package/src/components/Tabs/Tabs.test.ts +363 -0
  166. package/src/components/Tabs/Tabs.vue +318 -0
  167. package/src/components/Tabs/index.ts +2 -0
  168. package/src/components/Text/Text.test.ts +154 -0
  169. package/src/components/Text/Text.vue +100 -0
  170. package/src/components/Text/index.ts +2 -0
  171. package/src/components/Textarea/Textarea.test.ts +432 -0
  172. package/src/components/Textarea/Textarea.vue +411 -0
  173. package/src/components/Textarea/index.ts +2 -0
  174. package/src/components/TimePicker/TimePicker.test.ts +352 -0
  175. package/src/components/TimePicker/TimePicker.vue +569 -0
  176. package/src/components/TimePicker/index.ts +2 -0
  177. package/src/components/Timeline/Timeline.test.ts +193 -0
  178. package/src/components/Timeline/Timeline.vue +111 -0
  179. package/src/components/Timeline/TimelineItem.vue +167 -0
  180. package/src/components/Timeline/index.ts +13 -0
  181. package/src/components/Timeline/keys.ts +21 -0
  182. package/src/components/Toast/ToastItem.test.ts +289 -0
  183. package/src/components/Toast/ToastItem.vue +370 -0
  184. package/src/components/Toast/ToastProvider.test.ts +158 -0
  185. package/src/components/Toast/ToastProvider.vue +181 -0
  186. package/src/components/Toast/index.ts +83 -0
  187. package/src/components/Toast/toastState.test.ts +165 -0
  188. package/src/components/Toast/toastState.ts +161 -0
  189. package/src/components/ToggleButton/ToggleButton.test.ts +166 -0
  190. package/src/components/ToggleButton/ToggleButton.vue +197 -0
  191. package/src/components/ToggleButton/index.ts +2 -0
  192. package/src/components/ToggleGroup/ToggleGroup.test.ts +181 -0
  193. package/src/components/ToggleGroup/ToggleGroup.vue +130 -0
  194. package/src/components/ToggleGroup/index.ts +2 -0
  195. package/src/components/Tooltip/Tooltip.test.ts +238 -0
  196. package/src/components/Tooltip/Tooltip.vue +217 -0
  197. package/src/components/Tooltip/index.ts +2 -0
  198. package/src/components/TreeView/TreeView.test.ts +357 -0
  199. package/src/components/TreeView/TreeView.vue +251 -0
  200. package/src/components/TreeView/TreeViewItem.vue +288 -0
  201. package/src/components/TreeView/index.ts +11 -0
  202. package/src/components/TreeView/keys.ts +35 -0
  203. package/src/composables/index.ts +12 -0
  204. package/src/composables/useClickOutside.ts +36 -0
  205. package/src/composables/useClipboard.ts +35 -0
  206. package/src/composables/useEventListener.ts +48 -0
  207. package/src/composables/useFocusTrap.ts +58 -0
  208. package/src/composables/useHoverReveal.ts +98 -0
  209. package/src/composables/useId.ts +10 -0
  210. package/src/composables/useMagnetic.ts +171 -0
  211. package/src/composables/useRelativePosition.ts +127 -0
  212. package/src/composables/useRipple.ts +146 -0
  213. package/src/composables/useScrollLock.ts +25 -0
  214. package/src/composables/useSpireConfig.ts +27 -0
  215. package/src/composables/useStagger.ts +224 -0
  216. package/src/config/icons.test.ts +115 -0
  217. package/src/config/icons.ts +170 -0
  218. package/src/index.ts +361 -0
  219. package/src/styles/depth.css +129 -0
  220. package/src/styles/effects.css +169 -0
  221. package/src/styles/fallback.css +152 -0
  222. package/src/styles/main.css +25 -0
  223. package/src/styles/mood.css +211 -0
  224. package/src/styles/motion.css +159 -0
  225. package/src/styles/reset.css +97 -0
  226. package/src/styles/theme.css +708 -0
  227. package/src/styles/tokens.css +183 -0
  228. package/src/utils/.gitkeep +0 -0
  229. package/src/utils/color.ts +277 -0
  230. package/src/utils/date.test.ts +522 -0
  231. package/src/utils/date.ts +380 -0
  232. package/src/utils/index.ts +23 -0
  233. package/src/utils/object.test.ts +80 -0
  234. package/src/utils/object.ts +25 -0
  235. package/src/utils/string.test.ts +64 -0
  236. package/src/utils/string.ts +32 -0
  237. package/src/utils/time.ts +156 -0
@@ -0,0 +1,607 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { mount, VueWrapper } from '@vue/test-utils'
3
+ import { nextTick, h, defineComponent } from 'vue'
4
+ import Dropdown from './Dropdown.vue'
5
+ import DropdownItem from './DropdownItem.vue'
6
+ import DropdownSeparator from './DropdownSeparator.vue'
7
+ import DropdownSub from './DropdownSub.vue'
8
+ import DropdownSubTrigger from './DropdownSubTrigger.vue'
9
+ import DropdownSubContent from './DropdownSubContent.vue'
10
+
11
+ const TriggerButton = defineComponent({
12
+ template: '<button>Open Menu</button>'
13
+ })
14
+
15
+ describe('Dropdown', () => {
16
+ let wrapper: VueWrapper
17
+
18
+ beforeEach(() => {
19
+ document.body.innerHTML = ''
20
+ })
21
+
22
+ afterEach(() => {
23
+ wrapper?.unmount()
24
+ })
25
+
26
+ it('renders trigger slot', () => {
27
+ wrapper = mount(Dropdown, {
28
+ slots: {
29
+ trigger: () => h(TriggerButton)
30
+ }
31
+ })
32
+ expect(wrapper.find('.ui-dropdown__trigger').exists()).toBe(true)
33
+ expect(wrapper.text()).toContain('Open Menu')
34
+ })
35
+
36
+ it('opens menu on trigger click', async () => {
37
+ wrapper = mount(Dropdown, {
38
+ slots: {
39
+ trigger: () => h(TriggerButton),
40
+ default: () => h(DropdownItem, {}, () => 'Item 1')
41
+ },
42
+ attachTo: document.body
43
+ })
44
+
45
+ await wrapper.find('.ui-dropdown__trigger').trigger('click')
46
+ await nextTick()
47
+
48
+ const menu = document.body.querySelector('.ui-dropdown__menu')
49
+ expect(menu).toBeTruthy()
50
+ })
51
+
52
+ it('closes menu on second trigger click', async () => {
53
+ wrapper = mount(Dropdown, {
54
+ slots: {
55
+ trigger: () => h(TriggerButton),
56
+ default: () => h(DropdownItem, {}, () => 'Item 1')
57
+ },
58
+ attachTo: document.body
59
+ })
60
+
61
+ const trigger = wrapper.find('.ui-dropdown__trigger')
62
+ await trigger.trigger('click')
63
+ await nextTick()
64
+
65
+ expect(document.body.querySelector('.ui-dropdown__menu')).toBeTruthy()
66
+
67
+ await trigger.trigger('click')
68
+ await nextTick()
69
+
70
+ expect(document.body.querySelector('.ui-dropdown__menu')).toBeFalsy()
71
+ })
72
+
73
+ it('does not open when disabled', async () => {
74
+ wrapper = mount(Dropdown, {
75
+ props: { disabled: true },
76
+ slots: {
77
+ trigger: () => h(TriggerButton),
78
+ default: () => h(DropdownItem, {}, () => 'Item 1')
79
+ },
80
+ attachTo: document.body
81
+ })
82
+
83
+ await wrapper.find('.ui-dropdown__trigger').trigger('click')
84
+ await nextTick()
85
+
86
+ expect(document.body.querySelector('.ui-dropdown__menu')).toBeFalsy()
87
+ })
88
+
89
+ it('emits open event when opening', async () => {
90
+ wrapper = mount(Dropdown, {
91
+ slots: {
92
+ trigger: () => h(TriggerButton),
93
+ default: () => h(DropdownItem, {}, () => 'Item 1')
94
+ },
95
+ attachTo: document.body
96
+ })
97
+
98
+ await wrapper.find('.ui-dropdown__trigger').trigger('click')
99
+ await nextTick()
100
+
101
+ expect(wrapper.emitted('open')).toBeTruthy()
102
+ })
103
+
104
+ it('emits close event when closing', async () => {
105
+ wrapper = mount(Dropdown, {
106
+ slots: {
107
+ trigger: () => h(TriggerButton),
108
+ default: () => h(DropdownItem, {}, () => 'Item 1')
109
+ },
110
+ attachTo: document.body
111
+ })
112
+
113
+ const trigger = wrapper.find('.ui-dropdown__trigger')
114
+ await trigger.trigger('click')
115
+ await nextTick()
116
+ await trigger.trigger('click')
117
+ await nextTick()
118
+
119
+ expect(wrapper.emitted('close')).toBeTruthy()
120
+ })
121
+
122
+ it('sets correct ARIA attributes on trigger', () => {
123
+ wrapper = mount(Dropdown, {
124
+ slots: {
125
+ trigger: () => h(TriggerButton)
126
+ }
127
+ })
128
+
129
+ const trigger = wrapper.find('.ui-dropdown__trigger')
130
+ expect(trigger.attributes('aria-haspopup')).toBe('true')
131
+ expect(trigger.attributes('aria-expanded')).toBe('false')
132
+ expect(trigger.attributes('aria-controls')).toBeTruthy()
133
+ })
134
+
135
+ it('updates aria-expanded when open', async () => {
136
+ wrapper = mount(Dropdown, {
137
+ slots: {
138
+ trigger: () => h(TriggerButton),
139
+ default: () => h(DropdownItem, {}, () => 'Item 1')
140
+ },
141
+ attachTo: document.body
142
+ })
143
+
144
+ const trigger = wrapper.find('.ui-dropdown__trigger')
145
+ expect(trigger.attributes('aria-expanded')).toBe('false')
146
+
147
+ await trigger.trigger('click')
148
+ await nextTick()
149
+
150
+ expect(trigger.attributes('aria-expanded')).toBe('true')
151
+ })
152
+
153
+ it('menu has correct role and aria attributes', async () => {
154
+ wrapper = mount(Dropdown, {
155
+ slots: {
156
+ trigger: () => h(TriggerButton),
157
+ default: () => h(DropdownItem, {}, () => 'Item 1')
158
+ },
159
+ attachTo: document.body
160
+ })
161
+
162
+ await wrapper.find('.ui-dropdown__trigger').trigger('click')
163
+ await nextTick()
164
+
165
+ const menu = document.body.querySelector('.ui-dropdown__menu')
166
+ expect(menu?.getAttribute('role')).toBe('menu')
167
+ expect(menu?.getAttribute('aria-labelledby')).toBeTruthy()
168
+ })
169
+
170
+ it('opens menu on Enter key', async () => {
171
+ wrapper = mount(Dropdown, {
172
+ slots: {
173
+ trigger: () => h(TriggerButton),
174
+ default: () => h(DropdownItem, {}, () => 'Item 1')
175
+ },
176
+ attachTo: document.body
177
+ })
178
+
179
+ await wrapper.find('.ui-dropdown__trigger').trigger('keydown', { key: 'Enter' })
180
+ await nextTick()
181
+
182
+ expect(document.body.querySelector('.ui-dropdown__menu')).toBeTruthy()
183
+ })
184
+
185
+ it('opens menu on Space key', async () => {
186
+ wrapper = mount(Dropdown, {
187
+ slots: {
188
+ trigger: () => h(TriggerButton),
189
+ default: () => h(DropdownItem, {}, () => 'Item 1')
190
+ },
191
+ attachTo: document.body
192
+ })
193
+
194
+ await wrapper.find('.ui-dropdown__trigger').trigger('keydown', { key: ' ' })
195
+ await nextTick()
196
+
197
+ expect(document.body.querySelector('.ui-dropdown__menu')).toBeTruthy()
198
+ })
199
+
200
+ it('opens menu on ArrowDown key', async () => {
201
+ wrapper = mount(Dropdown, {
202
+ slots: {
203
+ trigger: () => h(TriggerButton),
204
+ default: () => h(DropdownItem, {}, () => 'Item 1')
205
+ },
206
+ attachTo: document.body
207
+ })
208
+
209
+ await wrapper.find('.ui-dropdown__trigger').trigger('keydown', { key: 'ArrowDown' })
210
+ await nextTick()
211
+
212
+ expect(document.body.querySelector('.ui-dropdown__menu')).toBeTruthy()
213
+ })
214
+
215
+ it('applies default placement bottom-start', async () => {
216
+ wrapper = mount(Dropdown, {
217
+ slots: {
218
+ trigger: () => h(TriggerButton),
219
+ default: () => h(DropdownItem, {}, () => 'Item 1')
220
+ },
221
+ attachTo: document.body
222
+ })
223
+
224
+ await wrapper.find('.ui-dropdown__trigger').trigger('click')
225
+ await nextTick()
226
+
227
+ const menu = document.body.querySelector('.ui-dropdown__menu') as HTMLElement
228
+ expect(menu).toBeTruthy()
229
+ })
230
+ })
231
+
232
+ describe('DropdownItem', () => {
233
+ it('renders as button by default', () => {
234
+ const wrapper = mount(DropdownItem, {
235
+ slots: { default: () => 'Edit' }
236
+ })
237
+
238
+ expect(wrapper.element.tagName).toBe('BUTTON')
239
+ expect(wrapper.attributes('type')).toBe('button')
240
+ expect(wrapper.attributes('role')).toBe('menuitem')
241
+ })
242
+
243
+ it('renders as anchor when href is provided', () => {
244
+ const wrapper = mount(DropdownItem, {
245
+ props: { href: 'https://example.com' },
246
+ slots: { default: () => 'External Link' }
247
+ })
248
+
249
+ expect(wrapper.element.tagName).toBe('A')
250
+ expect(wrapper.attributes('href')).toBe('https://example.com')
251
+ expect(wrapper.attributes('target')).toBe('_blank')
252
+ expect(wrapper.attributes('rel')).toBe('noopener noreferrer')
253
+ })
254
+
255
+ it('renders as router-link when to is provided', () => {
256
+ const wrapper = mount(DropdownItem, {
257
+ props: { to: '/settings' },
258
+ slots: { default: () => 'Settings' },
259
+ global: {
260
+ stubs: {
261
+ 'router-link': {
262
+ template: '<a><slot /></a>'
263
+ }
264
+ }
265
+ }
266
+ })
267
+
268
+ expect(wrapper.text()).toContain('Settings')
269
+ })
270
+
271
+ it('applies danger class when danger prop is true', () => {
272
+ const wrapper = mount(DropdownItem, {
273
+ props: { danger: true },
274
+ slots: { default: () => 'Delete' }
275
+ })
276
+
277
+ expect(wrapper.classes()).toContain('ui-dropdown-item--danger')
278
+ })
279
+
280
+ it('applies disabled class and attributes when disabled', () => {
281
+ const wrapper = mount(DropdownItem, {
282
+ props: { disabled: true },
283
+ slots: { default: () => 'Disabled' }
284
+ })
285
+
286
+ expect(wrapper.classes()).toContain('ui-dropdown-item--disabled')
287
+ expect(wrapper.attributes('aria-disabled')).toBe('true')
288
+ expect(wrapper.attributes('disabled')).toBeDefined()
289
+ expect(wrapper.attributes('tabindex')).toBe('-1')
290
+ })
291
+
292
+ it('renders icon when provided', () => {
293
+ const TestIcon = defineComponent({
294
+ template: '<svg data-testid="icon"></svg>'
295
+ })
296
+
297
+ const wrapper = mount(DropdownItem, {
298
+ props: { icon: TestIcon },
299
+ slots: { default: () => 'With Icon' }
300
+ })
301
+
302
+ expect(wrapper.find('.ui-dropdown-item__icon').exists()).toBe(true)
303
+ expect(wrapper.find('[data-testid="icon"]').exists()).toBe(true)
304
+ })
305
+
306
+ it('renders shortcut when provided', () => {
307
+ const wrapper = mount(DropdownItem, {
308
+ props: { shortcut: '⌘K' },
309
+ slots: { default: () => 'Search' }
310
+ })
311
+
312
+ expect(wrapper.find('.ui-dropdown-item__shortcut').exists()).toBe(true)
313
+ expect(wrapper.find('.ui-dropdown-item__shortcut').text()).toBe('⌘K')
314
+ })
315
+
316
+ it('emits click event', async () => {
317
+ const wrapper = mount(DropdownItem, {
318
+ slots: { default: () => 'Click Me' }
319
+ })
320
+
321
+ await wrapper.trigger('click')
322
+
323
+ expect(wrapper.emitted('click')).toBeTruthy()
324
+ })
325
+
326
+ it('does not emit click when disabled', async () => {
327
+ const wrapper = mount(DropdownItem, {
328
+ props: { disabled: true },
329
+ slots: { default: () => 'Disabled' }
330
+ })
331
+
332
+ await wrapper.trigger('click')
333
+
334
+ expect(wrapper.emitted('click')).toBeFalsy()
335
+ })
336
+ })
337
+
338
+ describe('DropdownSeparator', () => {
339
+ it('renders with correct role', () => {
340
+ const wrapper = mount(DropdownSeparator)
341
+
342
+ expect(wrapper.attributes('role')).toBe('separator')
343
+ expect(wrapper.classes()).toContain('ui-dropdown-separator')
344
+ })
345
+ })
346
+
347
+ describe('Dropdown keyboard navigation', () => {
348
+ let wrapper: VueWrapper
349
+
350
+ afterEach(() => {
351
+ wrapper?.unmount()
352
+ document.body.innerHTML = ''
353
+ })
354
+
355
+ it('closes menu on Escape key', async () => {
356
+ wrapper = mount(Dropdown, {
357
+ slots: {
358
+ trigger: () => h(TriggerButton),
359
+ default: () => [
360
+ h(DropdownItem, {}, () => 'Item 1'),
361
+ h(DropdownItem, {}, () => 'Item 2')
362
+ ]
363
+ },
364
+ attachTo: document.body
365
+ })
366
+
367
+ await wrapper.find('.ui-dropdown__trigger').trigger('click')
368
+ await nextTick()
369
+
370
+ const menu = document.body.querySelector('.ui-dropdown__menu')
371
+ expect(menu).toBeTruthy()
372
+
373
+ await wrapper.find('.ui-dropdown__trigger').trigger('click')
374
+ await nextTick()
375
+
376
+ const menuAfterOpen = document.body.querySelector('.ui-dropdown__menu')
377
+ if (menuAfterOpen) {
378
+ const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
379
+ menuAfterOpen.dispatchEvent(event)
380
+ await nextTick()
381
+ }
382
+ })
383
+ })
384
+
385
+ describe('DropdownSub', () => {
386
+ it('renders wrapper element', () => {
387
+ const wrapper = mount(DropdownSub, {
388
+ slots: { default: () => h('span', 'Content') }
389
+ })
390
+
391
+ expect(wrapper.classes()).toContain('ui-dropdown-sub')
392
+ expect(wrapper.text()).toContain('Content')
393
+ })
394
+
395
+ it('provides submenu context to children', () => {
396
+ const wrapper = mount(DropdownSub, {
397
+ slots: { default: () => h('span', 'Content') }
398
+ })
399
+
400
+ const vm = wrapper.vm as any
401
+ expect(typeof vm.open).toBe('function')
402
+ expect(typeof vm.close).toBe('function')
403
+ expect(typeof vm.scheduleClose).toBe('function')
404
+ expect(typeof vm.cancelClose).toBe('function')
405
+ })
406
+
407
+ it('exposes isOpen state', () => {
408
+ const wrapper = mount(DropdownSub)
409
+ const vm = wrapper.vm as any
410
+
411
+ expect(vm.isOpen).toBe(false)
412
+ vm.open()
413
+ expect(vm.isOpen).toBe(true)
414
+ vm.close()
415
+ expect(vm.isOpen).toBe(false)
416
+ })
417
+ })
418
+
419
+ describe('DropdownSubTrigger', () => {
420
+ const createSubTriggerInContext = (props = {}) => {
421
+ return mount(DropdownSub, {
422
+ slots: {
423
+ default: () => h(DropdownSubTrigger, props, () => 'More Options')
424
+ },
425
+ attachTo: document.body
426
+ })
427
+ }
428
+
429
+ afterEach(() => {
430
+ document.body.innerHTML = ''
431
+ })
432
+
433
+ it('renders as button with menuitem role', () => {
434
+ const wrapper = createSubTriggerInContext()
435
+ const trigger = wrapper.find('.ui-dropdown-sub-trigger')
436
+
437
+ expect(trigger.element.tagName).toBe('BUTTON')
438
+ expect(trigger.attributes('role')).toBe('menuitem')
439
+ expect(trigger.attributes('aria-haspopup')).toBe('true')
440
+ })
441
+
442
+ it('shows chevron icon', () => {
443
+ const wrapper = createSubTriggerInContext()
444
+ const chevron = wrapper.find('.ui-dropdown-sub-trigger__chevron')
445
+
446
+ expect(chevron.exists()).toBe(true)
447
+ expect(chevron.find('svg').exists()).toBe(true)
448
+ })
449
+
450
+ it('applies disabled styles when disabled', () => {
451
+ const wrapper = createSubTriggerInContext({ disabled: true })
452
+ const trigger = wrapper.find('.ui-dropdown-sub-trigger')
453
+
454
+ expect(trigger.classes()).toContain('ui-dropdown-sub-trigger--disabled')
455
+ expect(trigger.attributes('aria-disabled')).toBe('true')
456
+ expect(trigger.attributes('tabindex')).toBe('-1')
457
+ })
458
+
459
+ it('renders icon when provided', () => {
460
+ const TestIcon = defineComponent({
461
+ template: '<svg data-testid="sub-icon"></svg>'
462
+ })
463
+
464
+ const wrapper = mount(DropdownSub, {
465
+ slots: {
466
+ default: () => h(DropdownSubTrigger, { icon: TestIcon }, () => 'With Icon')
467
+ }
468
+ })
469
+
470
+ expect(wrapper.find('.ui-dropdown-sub-trigger__icon').exists()).toBe(true)
471
+ expect(wrapper.find('[data-testid="sub-icon"]').exists()).toBe(true)
472
+ })
473
+
474
+ it('opens submenu on ArrowRight key', async () => {
475
+ const wrapper = createSubTriggerInContext()
476
+ const trigger = wrapper.find('.ui-dropdown-sub-trigger')
477
+
478
+ await trigger.trigger('keydown', { key: 'ArrowRight' })
479
+ await nextTick()
480
+
481
+ const subVm = wrapper.vm as any
482
+ expect(subVm.isOpen).toBe(true)
483
+ })
484
+
485
+ it('opens submenu on Enter key', async () => {
486
+ const wrapper = createSubTriggerInContext()
487
+ const trigger = wrapper.find('.ui-dropdown-sub-trigger')
488
+
489
+ await trigger.trigger('keydown', { key: 'Enter' })
490
+ await nextTick()
491
+
492
+ const subVm = wrapper.vm as any
493
+ expect(subVm.isOpen).toBe(true)
494
+ })
495
+ })
496
+
497
+ describe('DropdownSubContent', () => {
498
+ afterEach(() => {
499
+ document.body.innerHTML = ''
500
+ })
501
+
502
+ it('renders when submenu is open', async () => {
503
+ const wrapper = mount(DropdownSub, {
504
+ slots: {
505
+ default: () => [
506
+ h(DropdownSubTrigger, {}, () => 'Trigger'),
507
+ h(DropdownSubContent, {}, () => h(DropdownItem, {}, () => 'Sub Item'))
508
+ ]
509
+ },
510
+ attachTo: document.body
511
+ })
512
+
513
+ const subVm = wrapper.vm as any
514
+ subVm.open()
515
+ await nextTick()
516
+
517
+ const content = document.body.querySelector('.ui-dropdown-sub-content')
518
+ expect(content).toBeTruthy()
519
+ expect(content?.getAttribute('role')).toBe('menu')
520
+ })
521
+
522
+ it('does not render when submenu is closed', async () => {
523
+ const wrapper = mount(DropdownSub, {
524
+ slots: {
525
+ default: () => [
526
+ h(DropdownSubTrigger, {}, () => 'Trigger'),
527
+ h(DropdownSubContent, {}, () => h(DropdownItem, {}, () => 'Sub Item'))
528
+ ]
529
+ },
530
+ attachTo: document.body
531
+ })
532
+
533
+ await nextTick()
534
+ const content = document.body.querySelector('.ui-dropdown-sub-content')
535
+ expect(content).toBeFalsy()
536
+ })
537
+
538
+ it('closes on ArrowLeft key', async () => {
539
+ const wrapper = mount(DropdownSub, {
540
+ slots: {
541
+ default: () => [
542
+ h(DropdownSubTrigger, {}, () => 'Trigger'),
543
+ h(DropdownSubContent, {}, () => h(DropdownItem, {}, () => 'Sub Item'))
544
+ ]
545
+ },
546
+ attachTo: document.body
547
+ })
548
+
549
+ const subVm = wrapper.vm as any
550
+ subVm.open()
551
+ await nextTick()
552
+
553
+ const content = document.body.querySelector('.ui-dropdown-sub-content')
554
+ expect(content).toBeTruthy()
555
+
556
+ const event = new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })
557
+ content?.dispatchEvent(event)
558
+ await nextTick()
559
+
560
+ expect(subVm.isOpen).toBe(false)
561
+ })
562
+
563
+ it('closes on Escape key', async () => {
564
+ const wrapper = mount(DropdownSub, {
565
+ slots: {
566
+ default: () => [
567
+ h(DropdownSubTrigger, {}, () => 'Trigger'),
568
+ h(DropdownSubContent, {}, () => h(DropdownItem, {}, () => 'Sub Item'))
569
+ ]
570
+ },
571
+ attachTo: document.body
572
+ })
573
+
574
+ const subVm = wrapper.vm as any
575
+ subVm.open()
576
+ await nextTick()
577
+
578
+ const content = document.body.querySelector('.ui-dropdown-sub-content')
579
+ expect(content).toBeTruthy()
580
+
581
+ const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
582
+ content?.dispatchEvent(event)
583
+ await nextTick()
584
+
585
+ expect(subVm.isOpen).toBe(false)
586
+ })
587
+
588
+ it('renders outside of wrapper component', async () => {
589
+ const wrapper = mount(DropdownSub, {
590
+ slots: {
591
+ default: () => [
592
+ h(DropdownSubTrigger, {}, () => 'Trigger'),
593
+ h(DropdownSubContent, {}, () => h(DropdownItem, {}, () => 'Sub Item'))
594
+ ]
595
+ },
596
+ attachTo: document.body
597
+ })
598
+
599
+ const subVm = wrapper.vm as any
600
+ subVm.open()
601
+ await nextTick()
602
+
603
+ const content = document.body.querySelector('.ui-dropdown-sub-content')
604
+ expect(content).toBeTruthy()
605
+ expect(wrapper.find('.ui-dropdown-sub-content').exists()).toBe(false)
606
+ })
607
+ })