@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,807 @@
1
+ <script lang="ts">
2
+ import type { InjectionKey, Ref } from 'vue'
3
+
4
+ export type DropdownPlacement =
5
+ | 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end'
6
+ | 'right-start' | 'right-end' | 'left-start' | 'left-end'
7
+
8
+ export interface DropdownProps {
9
+ /** Menu placement relative to trigger */
10
+ placement?: DropdownPlacement
11
+ /** Offset from trigger (px) */
12
+ offset?: number
13
+ /** Disable the dropdown */
14
+ disabled?: boolean
15
+ /** Width of the menu ('auto', 'trigger', or px value) */
16
+ menuWidth?: 'auto' | 'trigger' | number
17
+ }
18
+
19
+ export interface DropdownContext {
20
+ close: () => void
21
+ closeAll: () => void
22
+ cancelClose: () => void
23
+ registerItem: (el: HTMLElement) => void
24
+ unregisterItem: (el: HTMLElement) => void
25
+ registerSubmenu: (submenu: SubmenuRegistration) => void
26
+ unregisterSubmenu: (id: string) => void
27
+ isSubmenu: boolean
28
+ depth: number
29
+ isMobile: Ref<boolean>
30
+ openSubmenuId: Ref<string | null>
31
+ setOpenSubmenu: (id: string | null) => void
32
+ safeTriangle: SafeTriangleState
33
+ menuStack: Ref<string[]>
34
+ pushMenu: (id: string, label: string) => void
35
+ popMenu: () => void
36
+ }
37
+
38
+ export interface SubmenuRegistration {
39
+ id: string
40
+ triggerEl: HTMLElement
41
+ open: () => void
42
+ close: () => void
43
+ }
44
+
45
+ export interface SafeTriangleState {
46
+ active: boolean
47
+ points: { x: number; y: number }[]
48
+ submenuId: string | null
49
+ }
50
+
51
+ export const dropdownContextKey: InjectionKey<DropdownContext> = Symbol('dropdown')
52
+ </script>
53
+
54
+ <script setup lang="ts">
55
+ import {
56
+ ref,
57
+ reactive,
58
+ computed,
59
+ watch,
60
+ nextTick,
61
+ onMounted,
62
+ onUnmounted,
63
+ provide,
64
+ inject
65
+ } from 'vue'
66
+ import { useId } from '../../composables'
67
+
68
+ const props = withDefaults(defineProps<DropdownProps>(), {
69
+ placement: 'bottom-start',
70
+ offset: 4,
71
+ disabled: false,
72
+ menuWidth: 'auto'
73
+ })
74
+
75
+ const emit = defineEmits<{
76
+ open: []
77
+ close: []
78
+ }>()
79
+
80
+ const parentContext = inject(dropdownContextKey, null)
81
+ const isSubmenu = !!parentContext
82
+ const depth = parentContext ? parentContext.depth + 1 : 0
83
+
84
+ const isOpen = ref(false)
85
+ const triggerRef = ref<HTMLElement | null>(null)
86
+ const menuRef = ref<HTMLElement | null>(null)
87
+ const menuItems = ref<HTMLElement[]>([])
88
+ const submenus = ref<Map<string, SubmenuRegistration>>(new Map())
89
+ const focusedIndex = ref(-1)
90
+ const openSubmenuId = ref<string | null>(null)
91
+
92
+ const triggerId = useId('dropdown-trigger')
93
+ const menuId = useId('dropdown-menu')
94
+ const submenuId = useId('submenu')
95
+
96
+ const isMobile = ref(false)
97
+ const menuStack = ref<string[]>([])
98
+ const menuLabels = ref<Map<string, string>>(new Map())
99
+
100
+ let closeTimeout: ReturnType<typeof setTimeout> | null = null
101
+ let hoverIntent: { x: number; y: number } | null = null
102
+
103
+ const safeTriangle = reactive<SafeTriangleState>({
104
+ active: false,
105
+ points: [],
106
+ submenuId: null
107
+ })
108
+
109
+ function checkMobile() {
110
+ isMobile.value = window.matchMedia('(max-width: 768px)').matches ||
111
+ 'ontouchstart' in window ||
112
+ navigator.maxTouchPoints > 0
113
+ }
114
+
115
+ onMounted(() => {
116
+ checkMobile()
117
+ window.addEventListener('resize', checkMobile)
118
+
119
+ if (isSubmenu && parentContext) {
120
+ parentContext.registerSubmenu({
121
+ id: submenuId,
122
+ triggerEl: triggerRef.value!,
123
+ open,
124
+ close
125
+ })
126
+ }
127
+ })
128
+
129
+ onUnmounted(() => {
130
+ window.removeEventListener('resize', checkMobile)
131
+ if (isSubmenu && parentContext) {
132
+ parentContext.unregisterSubmenu(submenuId)
133
+ }
134
+ })
135
+
136
+ const effectivePlacement = computed(() => {
137
+ if (isSubmenu && props.placement === 'bottom-start') {
138
+ return 'right-start'
139
+ }
140
+ return props.placement
141
+ })
142
+
143
+ function registerItem(el: HTMLElement) {
144
+ if (!menuItems.value.includes(el)) {
145
+ menuItems.value.push(el)
146
+ }
147
+ }
148
+
149
+ function unregisterItem(el: HTMLElement) {
150
+ const index = menuItems.value.indexOf(el)
151
+ if (index > -1) {
152
+ menuItems.value.splice(index, 1)
153
+ }
154
+ }
155
+
156
+ function registerSubmenu(submenu: SubmenuRegistration) {
157
+ submenus.value.set(submenu.id, submenu)
158
+ }
159
+
160
+ function unregisterSubmenu(id: string) {
161
+ submenus.value.delete(id)
162
+ }
163
+
164
+ function setOpenSubmenu(id: string | null) {
165
+ if (openSubmenuId.value && openSubmenuId.value !== id) {
166
+ const prev = submenus.value.get(openSubmenuId.value)
167
+ prev?.close()
168
+ }
169
+ openSubmenuId.value = id
170
+ }
171
+
172
+ function pushMenu(id: string, label: string) {
173
+ menuLabels.value.set(id, label)
174
+ menuStack.value.push(id)
175
+ }
176
+
177
+ function popMenu() {
178
+ const id = menuStack.value.pop()
179
+ if (id) {
180
+ menuLabels.value.delete(id)
181
+ const submenu = submenus.value.get(id)
182
+ submenu?.close()
183
+ }
184
+ }
185
+
186
+ provide(dropdownContextKey, {
187
+ close,
188
+ closeAll,
189
+ cancelClose,
190
+ registerItem,
191
+ unregisterItem,
192
+ registerSubmenu,
193
+ unregisterSubmenu,
194
+ isSubmenu,
195
+ depth,
196
+ isMobile,
197
+ openSubmenuId,
198
+ setOpenSubmenu,
199
+ safeTriangle,
200
+ menuStack,
201
+ pushMenu,
202
+ popMenu
203
+ })
204
+
205
+ function open() {
206
+ if (props.disabled || isOpen.value) return
207
+ isOpen.value = true
208
+ emit('open')
209
+
210
+ if (isSubmenu && parentContext) {
211
+ parentContext.setOpenSubmenu(submenuId)
212
+ }
213
+
214
+ nextTick(() => {
215
+ updatePosition()
216
+ requestAnimationFrame(() => {
217
+ focusFirstItem()
218
+ })
219
+ })
220
+ }
221
+
222
+ function close() {
223
+ if (!isOpen.value) return
224
+
225
+ submenus.value.forEach(sub => sub.close())
226
+ openSubmenuId.value = null
227
+
228
+ isOpen.value = false
229
+ focusedIndex.value = -1
230
+ emit('close')
231
+
232
+ if (!isSubmenu) {
233
+ menuStack.value = []
234
+ menuLabels.value.clear()
235
+ }
236
+
237
+ if (isSubmenu && parentContext) {
238
+ parentContext.setOpenSubmenu(null)
239
+ }
240
+
241
+ nextTick(() => {
242
+ triggerRef.value?.focus()
243
+ })
244
+ }
245
+
246
+ function closeAll() {
247
+ close()
248
+ parentContext?.closeAll()
249
+ }
250
+
251
+ function toggle() {
252
+ if (isOpen.value) {
253
+ close()
254
+ } else {
255
+ open()
256
+ }
257
+ }
258
+
259
+ function scheduleClose() {
260
+ if (closeTimeout) clearTimeout(closeTimeout)
261
+ closeTimeout = setTimeout(() => {
262
+ close()
263
+ }, 150)
264
+ }
265
+
266
+ function cancelClose() {
267
+ if (closeTimeout) {
268
+ clearTimeout(closeTimeout)
269
+ closeTimeout = null
270
+ }
271
+ }
272
+
273
+ function focusFirstItem() {
274
+ if (!menuRef.value) return
275
+ const firstItem = menuRef.value.querySelector<HTMLElement>(
276
+ ':scope > .ui-dropdown-item:not([disabled]):not([aria-disabled="true"]), :scope > .ui-dropdown > .ui-dropdown__trigger .ui-dropdown-item:not([disabled]):not([aria-disabled="true"])'
277
+ )
278
+ if (firstItem) {
279
+ focusedIndex.value = 0
280
+ firstItem.focus()
281
+ }
282
+ }
283
+
284
+ function getFocusableItems(): HTMLElement[] {
285
+ if (!menuRef.value) return []
286
+ return Array.from(
287
+ menuRef.value.querySelectorAll<HTMLElement>(
288
+ ':scope > .ui-dropdown-item:not([disabled]):not([aria-disabled="true"]), :scope > .ui-dropdown > .ui-dropdown__trigger .ui-dropdown-item:not([disabled]):not([aria-disabled="true"])'
289
+ )
290
+ )
291
+ }
292
+
293
+ function focusItem(index: number) {
294
+ const focusableItems = getFocusableItems()
295
+ if (focusableItems.length === 0) return
296
+
297
+ if (index < 0) index = focusableItems.length - 1
298
+ if (index >= focusableItems.length) index = 0
299
+
300
+ focusedIndex.value = index
301
+ focusableItems[index].focus()
302
+ }
303
+
304
+ function handleTriggerClick(event: MouseEvent) {
305
+ if (isSubmenu && !isMobile.value) {
306
+ return
307
+ }
308
+ toggle()
309
+ }
310
+
311
+ function handleTriggerMouseEnter() {
312
+ if (!isSubmenu || isMobile.value) return
313
+ cancelClose()
314
+ parentContext?.cancelClose?.()
315
+ open()
316
+ }
317
+
318
+ function handleTriggerMouseLeave(event: MouseEvent) {
319
+ if (!isSubmenu || isMobile.value) return
320
+
321
+ if (menuRef.value) {
322
+ const menuRect = menuRef.value.getBoundingClientRect()
323
+ hoverIntent = { x: event.clientX, y: event.clientY }
324
+
325
+ safeTriangle.active = true
326
+ safeTriangle.submenuId = submenuId
327
+ safeTriangle.points = [
328
+ { x: event.clientX, y: event.clientY },
329
+ { x: menuRect.left, y: menuRect.top },
330
+ { x: menuRect.left, y: menuRect.bottom }
331
+ ]
332
+ }
333
+
334
+ scheduleClose()
335
+ }
336
+
337
+ function handleMenuMouseEnter() {
338
+ cancelClose()
339
+ parentContext?.cancelClose?.()
340
+ safeTriangle.active = false
341
+ }
342
+
343
+ function handleMenuMouseLeave() {
344
+ if (isSubmenu) {
345
+ scheduleClose()
346
+ }
347
+ }
348
+
349
+ function handleTriggerKeydown(event: KeyboardEvent) {
350
+ if (isSubmenu) {
351
+ switch (event.key) {
352
+ case 'ArrowRight':
353
+ case 'Enter':
354
+ event.preventDefault()
355
+ event.stopPropagation()
356
+ open()
357
+ break
358
+ }
359
+ return
360
+ }
361
+
362
+ switch (event.key) {
363
+ case 'Enter':
364
+ case ' ':
365
+ case 'ArrowDown':
366
+ event.preventDefault()
367
+ open()
368
+ break
369
+ case 'ArrowUp':
370
+ event.preventDefault()
371
+ open()
372
+ nextTick(() => {
373
+ const items = getFocusableItems()
374
+ if (items.length > 0) {
375
+ focusedIndex.value = items.length - 1
376
+ items[items.length - 1].focus()
377
+ }
378
+ })
379
+ break
380
+ }
381
+ }
382
+
383
+ function handleMenuKeydown(event: KeyboardEvent) {
384
+ const focusableItems = getFocusableItems()
385
+ const currentIndex = focusableItems.findIndex(el => el === document.activeElement)
386
+
387
+ switch (event.key) {
388
+ case 'ArrowDown':
389
+ event.preventDefault()
390
+ focusItem(currentIndex + 1)
391
+ break
392
+ case 'ArrowUp':
393
+ event.preventDefault()
394
+ focusItem(currentIndex - 1)
395
+ break
396
+ case 'ArrowLeft':
397
+ if (isSubmenu) {
398
+ event.preventDefault()
399
+ event.stopPropagation()
400
+ close()
401
+ }
402
+ break
403
+ case 'Home':
404
+ event.preventDefault()
405
+ focusItem(0)
406
+ break
407
+ case 'End':
408
+ event.preventDefault()
409
+ focusItem(focusableItems.length - 1)
410
+ break
411
+ case 'Escape':
412
+ event.preventDefault()
413
+ event.stopPropagation()
414
+ close()
415
+ break
416
+ case 'Tab':
417
+ closeAll()
418
+ break
419
+ }
420
+ }
421
+
422
+ const menuStyle = computed(() => {
423
+ const style: Record<string, string> = {}
424
+
425
+ if (props.menuWidth === 'trigger' && triggerRef.value) {
426
+ style.minWidth = `${triggerRef.value.offsetWidth}px`
427
+ } else if (typeof props.menuWidth === 'number') {
428
+ style.width = `${props.menuWidth}px`
429
+ }
430
+
431
+ return style
432
+ })
433
+
434
+ const positionStyle = ref<{ top: string; left: string }>({ top: '0', left: '0' })
435
+
436
+ function updatePosition() {
437
+ if (!triggerRef.value || !menuRef.value) return
438
+
439
+ const triggerRect = triggerRef.value.getBoundingClientRect()
440
+ const menuRect = menuRef.value.getBoundingClientRect()
441
+ const viewport = { width: window.innerWidth, height: window.innerHeight }
442
+
443
+ let top: number
444
+ let left: number
445
+ let placement = effectivePlacement.value
446
+
447
+ if (placement.startsWith('right') || placement.startsWith('left')) {
448
+ const spaceRight = viewport.width - triggerRect.right
449
+ const spaceLeft = triggerRect.left
450
+ const needsFlipHorizontal =
451
+ (placement.startsWith('right') && spaceRight < menuRect.width && spaceLeft > spaceRight) ||
452
+ (placement.startsWith('left') && spaceLeft < menuRect.width && spaceRight > spaceLeft)
453
+
454
+ if (needsFlipHorizontal) {
455
+ placement = placement.startsWith('right')
456
+ ? placement.replace('right', 'left') as DropdownPlacement
457
+ : placement.replace('left', 'right') as DropdownPlacement
458
+ }
459
+
460
+ if (placement.startsWith('right')) {
461
+ left = triggerRect.right + props.offset
462
+ } else {
463
+ left = triggerRect.left - menuRect.width - props.offset
464
+ }
465
+
466
+ if (placement.endsWith('start')) {
467
+ top = triggerRect.top
468
+ } else {
469
+ top = triggerRect.bottom - menuRect.height
470
+ }
471
+ } else {
472
+ const spaceBelow = viewport.height - triggerRect.bottom
473
+ const spaceAbove = triggerRect.top
474
+ const needsFlipVertical =
475
+ (placement.startsWith('bottom') && spaceBelow < menuRect.height && spaceAbove > spaceBelow) ||
476
+ (placement.startsWith('top') && spaceAbove < menuRect.height && spaceBelow > spaceAbove)
477
+
478
+ if (needsFlipVertical) {
479
+ placement = placement.startsWith('bottom')
480
+ ? placement.replace('bottom', 'top') as DropdownPlacement
481
+ : placement.replace('top', 'bottom') as DropdownPlacement
482
+ }
483
+
484
+ if (placement.startsWith('bottom')) {
485
+ top = triggerRect.bottom + props.offset
486
+ } else {
487
+ top = triggerRect.top - menuRect.height - props.offset
488
+ }
489
+
490
+ if (placement.endsWith('start')) {
491
+ left = triggerRect.left
492
+ } else {
493
+ left = triggerRect.right - menuRect.width
494
+ }
495
+ }
496
+
497
+ left = Math.max(8, Math.min(left, viewport.width - menuRect.width - 8))
498
+ top = Math.max(8, Math.min(top, viewport.height - menuRect.height - 8))
499
+
500
+ positionStyle.value = {
501
+ top: `${top}px`,
502
+ left: `${left}px`
503
+ }
504
+ }
505
+
506
+ function isPointInTriangle(px: number, py: number, points: { x: number; y: number }[]): boolean {
507
+ if (points.length < 3) return false
508
+
509
+ const [p0, p1, p2] = points
510
+
511
+ const area = 0.5 * (-p1.y * p2.x + p0.y * (-p1.x + p2.x) + p0.x * (p1.y - p2.y) + p1.x * p2.y)
512
+ const sign = area < 0 ? -1 : 1
513
+
514
+ const s = (p0.y * p2.x - p0.x * p2.y + (p2.y - p0.y) * px + (p0.x - p2.x) * py) * sign
515
+ const t = (p0.x * p1.y - p0.y * p1.x + (p0.y - p1.y) * px + (p1.x - p0.x) * py) * sign
516
+
517
+ return s > 0 && t > 0 && (s + t) < 2 * area * sign
518
+ }
519
+
520
+ function handleDocumentMouseMove(event: MouseEvent) {
521
+ if (!safeTriangle.active || !safeTriangle.points.length) return
522
+
523
+ const inTriangle = isPointInTriangle(event.clientX, event.clientY, safeTriangle.points)
524
+
525
+ if (inTriangle) {
526
+ cancelClose()
527
+ } else {
528
+ safeTriangle.active = false
529
+ }
530
+ }
531
+
532
+ function handleClickOutside(event: MouseEvent) {
533
+ const target = event.target as Node
534
+
535
+ let isInsideAnySubmenu = false
536
+ submenus.value.forEach(sub => {
537
+ const submenuEl = document.getElementById(`dropdown-menu-${sub.id.split('-').pop()}`)
538
+ if (submenuEl?.contains(target)) {
539
+ isInsideAnySubmenu = true
540
+ }
541
+ })
542
+
543
+ if (
544
+ !triggerRef.value?.contains(target) &&
545
+ !menuRef.value?.contains(target) &&
546
+ !isInsideAnySubmenu
547
+ ) {
548
+ closeAll()
549
+ }
550
+ }
551
+
552
+ watch(isOpen, (open) => {
553
+ if (open) {
554
+ document.addEventListener('mousedown', handleClickOutside)
555
+ document.addEventListener('mousemove', handleDocumentMouseMove)
556
+ window.addEventListener('resize', updatePosition)
557
+ window.addEventListener('scroll', updatePosition, true)
558
+ } else {
559
+ document.removeEventListener('mousedown', handleClickOutside)
560
+ document.removeEventListener('mousemove', handleDocumentMouseMove)
561
+ window.removeEventListener('resize', updatePosition)
562
+ window.removeEventListener('scroll', updatePosition, true)
563
+ safeTriangle.active = false
564
+ }
565
+ })
566
+
567
+ onUnmounted(() => {
568
+ document.removeEventListener('mousedown', handleClickOutside)
569
+ document.removeEventListener('mousemove', handleDocumentMouseMove)
570
+ window.removeEventListener('resize', updatePosition)
571
+ window.removeEventListener('scroll', updatePosition, true)
572
+ if (closeTimeout) clearTimeout(closeTimeout)
573
+ })
574
+
575
+ const currentMenuLabel = computed(() => {
576
+ if (menuStack.value.length === 0) return null
577
+ const currentId = menuStack.value[menuStack.value.length - 1]
578
+ return menuLabels.value.get(currentId) || null
579
+ })
580
+
581
+ const showMobileDrilldown = computed(() => {
582
+ return isMobile.value && !isSubmenu && menuStack.value.length > 0
583
+ })
584
+ </script>
585
+
586
+ <template>
587
+ <div
588
+ class="ui-dropdown"
589
+ :class="{ 'ui-dropdown--submenu': isSubmenu }"
590
+ >
591
+ <div
592
+ ref="triggerRef"
593
+ class="ui-dropdown__trigger"
594
+ :id="triggerId"
595
+ :aria-haspopup="true"
596
+ :aria-expanded="isOpen"
597
+ :aria-controls="menuId"
598
+ @click="handleTriggerClick"
599
+ @mouseenter="handleTriggerMouseEnter"
600
+ @mouseleave="handleTriggerMouseLeave"
601
+ @keydown="handleTriggerKeydown"
602
+ >
603
+ <slot name="trigger" :open="isOpen" :toggle="toggle" />
604
+ </div>
605
+
606
+ <Teleport to="body">
607
+ <Transition :name="isSubmenu ? 'ui-dropdown-sub' : 'ui-dropdown'">
608
+ <div
609
+ v-if="isOpen"
610
+ ref="menuRef"
611
+ class="ui-dropdown__menu"
612
+ :class="{
613
+ 'ui-dropdown__menu--submenu': isSubmenu,
614
+ 'ui-dropdown__menu--mobile': isMobile && !isSubmenu
615
+ }"
616
+ :id="menuId"
617
+ role="menu"
618
+ :aria-labelledby="triggerId"
619
+ :style="isMobile && !isSubmenu ? {} : [menuStyle, positionStyle]"
620
+ @mouseenter="handleMenuMouseEnter"
621
+ @mouseleave="handleMenuMouseLeave"
622
+ @keydown="handleMenuKeydown"
623
+ >
624
+ <!-- Mobile drill-down header -->
625
+ <div
626
+ v-if="isMobile && !isSubmenu && menuStack.length > 0"
627
+ class="ui-dropdown__mobile-header"
628
+ >
629
+ <button
630
+ type="button"
631
+ class="ui-dropdown__back-btn"
632
+ @click="popMenu"
633
+ >
634
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
635
+ <path d="M15 18l-6-6 6-6" />
636
+ </svg>
637
+ Back
638
+ </button>
639
+ <span class="ui-dropdown__mobile-title">{{ currentMenuLabel }}</span>
640
+ </div>
641
+
642
+ <slot />
643
+ </div>
644
+ </Transition>
645
+
646
+ <!-- Mobile overlay -->
647
+ <Transition name="ui-dropdown-overlay">
648
+ <div
649
+ v-if="isOpen && isMobile && !isSubmenu"
650
+ class="ui-dropdown__overlay"
651
+ @click="closeAll"
652
+ />
653
+ </Transition>
654
+ </Teleport>
655
+ </div>
656
+ </template>
657
+
658
+ <style scoped>
659
+ .ui-dropdown {
660
+ display: inline-block;
661
+ position: relative;
662
+ }
663
+
664
+ .ui-dropdown--submenu {
665
+ width: 100%;
666
+ }
667
+
668
+ .ui-dropdown__trigger {
669
+ display: inline-flex;
670
+ }
671
+
672
+ .ui-dropdown--submenu .ui-dropdown__trigger {
673
+ display: flex;
674
+ width: 100%;
675
+ }
676
+
677
+ .ui-dropdown__menu {
678
+ position: fixed;
679
+ z-index: var(--z-dropdown, 50);
680
+ min-width: 160px;
681
+ padding: var(--space-1);
682
+ background: var(--dropdown-bg);
683
+ border: 1px solid var(--dropdown-border);
684
+ border-radius: var(--radius-lg);
685
+ box-shadow: var(--shadow-lg);
686
+ outline: none;
687
+ font-family: var(--font-sans);
688
+ display: flex;
689
+ flex-direction: column;
690
+ gap: 2px;
691
+ }
692
+
693
+ .ui-dropdown__menu--submenu {
694
+ z-index: calc(var(--z-dropdown, 50) + 1);
695
+ }
696
+
697
+ .ui-dropdown__menu--mobile {
698
+ position: fixed;
699
+ top: auto !important;
700
+ left: 0 !important;
701
+ right: 0;
702
+ bottom: 0;
703
+ width: 100%;
704
+ max-height: 80vh;
705
+ border-radius: var(--radius-xl) var(--radius-xl) 0 0;
706
+ padding: var(--space-2);
707
+ overflow-y: auto;
708
+ z-index: calc(var(--z-dropdown, 50) + 10);
709
+ }
710
+
711
+ .ui-dropdown__mobile-header {
712
+ display: flex;
713
+ align-items: center;
714
+ gap: var(--space-2);
715
+ padding: var(--space-2);
716
+ border-bottom: 1px solid var(--dropdown-border);
717
+ margin: calc(-1 * var(--space-2));
718
+ margin-bottom: var(--space-2);
719
+ }
720
+
721
+ .ui-dropdown__back-btn {
722
+ display: flex;
723
+ align-items: center;
724
+ gap: var(--space-1);
725
+ padding: var(--space-1) var(--space-2);
726
+ font-size: var(--text-sm);
727
+ color: var(--dropdown-item-text);
728
+ background: transparent;
729
+ border: none;
730
+ border-radius: var(--radius-md);
731
+ cursor: pointer;
732
+ }
733
+
734
+ .ui-dropdown__back-btn:hover {
735
+ background: var(--dropdown-item-hover);
736
+ }
737
+
738
+ .ui-dropdown__mobile-title {
739
+ font-size: var(--text-sm);
740
+ font-weight: var(--font-medium);
741
+ color: var(--dropdown-item-text);
742
+ }
743
+
744
+ .ui-dropdown__overlay {
745
+ position: fixed;
746
+ inset: 0;
747
+ background: rgba(0, 0, 0, 0.4);
748
+ z-index: calc(var(--z-dropdown, 50) + 9);
749
+ }
750
+
751
+ .ui-dropdown-enter-active {
752
+ transition: opacity var(--duration-fast) var(--ease-default),
753
+ transform var(--duration-fast) var(--ease-default);
754
+ }
755
+
756
+ .ui-dropdown-leave-active {
757
+ transition: opacity var(--duration-fast) var(--ease-default),
758
+ transform var(--duration-fast) var(--ease-default);
759
+ }
760
+
761
+ .ui-dropdown-enter-from,
762
+ .ui-dropdown-leave-to {
763
+ opacity: 0;
764
+ transform: scale(0.95) translateY(-4px);
765
+ }
766
+
767
+ .ui-dropdown-sub-enter-active {
768
+ transition: opacity var(--duration-fast) var(--ease-default),
769
+ transform var(--duration-fast) var(--ease-default);
770
+ }
771
+
772
+ .ui-dropdown-sub-leave-active {
773
+ transition: opacity var(--duration-fast) var(--ease-default),
774
+ transform var(--duration-fast) var(--ease-default);
775
+ }
776
+
777
+ .ui-dropdown-sub-enter-from,
778
+ .ui-dropdown-sub-leave-to {
779
+ opacity: 0;
780
+ transform: translateX(-4px);
781
+ }
782
+
783
+ .ui-dropdown-overlay-enter-active,
784
+ .ui-dropdown-overlay-leave-active {
785
+ transition: opacity var(--duration-normal) var(--ease-default);
786
+ }
787
+
788
+ .ui-dropdown-overlay-enter-from,
789
+ .ui-dropdown-overlay-leave-to {
790
+ opacity: 0;
791
+ }
792
+
793
+ @media (max-width: 768px) {
794
+ .ui-dropdown__menu--mobile {
795
+ animation: ui-dropdown-slide-up var(--duration-normal) var(--ease-default);
796
+ }
797
+ }
798
+
799
+ @keyframes ui-dropdown-slide-up {
800
+ from {
801
+ transform: translateY(100%);
802
+ }
803
+ to {
804
+ transform: translateY(0);
805
+ }
806
+ }
807
+ </style>