@datametria/vue-components 2.3.1 → 2.4.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 (126) hide show
  1. package/README.md +625 -594
  2. package/dist/index.es.js +1962 -1887
  3. package/dist/index.umd.js +6 -6
  4. package/dist/src/components/DatametriaForm.vue.d.ts +1 -1
  5. package/dist/src/components/DatametriaInput.vue.d.ts +9 -1
  6. package/dist/src/components/DatametriaSelect.vue.d.ts +10 -1
  7. package/dist/vue-components.css +1 -1
  8. package/package.json +103 -102
  9. package/src/components/DatametriaAlert.vue +151 -133
  10. package/src/components/DatametriaAutocomplete.vue +250 -229
  11. package/src/components/DatametriaAvatar.vue +256 -238
  12. package/src/components/DatametriaBadge.vue +101 -96
  13. package/src/components/DatametriaBreadcrumb.vue +132 -128
  14. package/src/components/DatametriaButton.vue +191 -173
  15. package/src/components/DatametriaCard.vue +84 -66
  16. package/src/components/DatametriaCheckbox.vue +197 -193
  17. package/src/components/DatametriaChip.vue +159 -141
  18. package/src/components/DatametriaContainer.vue +70 -52
  19. package/src/components/DatametriaDataTable.vue +318 -300
  20. package/src/components/DatametriaDatePicker.vue +396 -378
  21. package/src/components/DatametriaDialog.vue +297 -293
  22. package/src/components/DatametriaDivider.vue +105 -98
  23. package/src/components/DatametriaDropdown.vue +356 -350
  24. package/src/components/DatametriaEmpty.vue +155 -151
  25. package/src/components/DatametriaFileUpload.vue +413 -395
  26. package/src/components/DatametriaFloatingBar.vue +144 -126
  27. package/src/components/DatametriaForm.vue +174 -156
  28. package/src/components/DatametriaFormItem.vue +183 -179
  29. package/src/components/DatametriaGrid.vue +55 -37
  30. package/src/components/DatametriaInput.vue +314 -263
  31. package/src/components/DatametriaMenu.vue +618 -600
  32. package/src/components/DatametriaModal.vue +147 -129
  33. package/src/components/DatametriaNavbar.vue +277 -223
  34. package/src/components/DatametriaPagination.vue +375 -371
  35. package/src/components/DatametriaPasswordInput.vue +446 -426
  36. package/src/components/DatametriaPopconfirm.vue +240 -234
  37. package/src/components/DatametriaProgress.vue +228 -224
  38. package/src/components/DatametriaRadio.vue +151 -147
  39. package/src/components/DatametriaResult.vue +135 -131
  40. package/src/components/DatametriaSelect.vue +311 -211
  41. package/src/components/DatametriaSidebar.vue +294 -222
  42. package/src/components/DatametriaSkeleton.vue +257 -234
  43. package/src/components/DatametriaSlider.vue +409 -391
  44. package/src/components/DatametriaSortableTable.vue +826 -802
  45. package/src/components/DatametriaSpinner.vue +114 -110
  46. package/src/components/DatametriaSteps.vue +318 -312
  47. package/src/components/DatametriaSwitch.vue +146 -142
  48. package/src/components/DatametriaTabPane.vue +94 -76
  49. package/src/components/DatametriaTable.vue +118 -100
  50. package/src/components/DatametriaTabs.vue +315 -297
  51. package/src/components/DatametriaTextarea.vue +213 -195
  52. package/src/components/DatametriaTimePicker.vue +317 -299
  53. package/src/components/DatametriaToast.vue +176 -176
  54. package/src/components/DatametriaTooltip.vue +421 -400
  55. package/src/components/DatametriaTree.vue +126 -122
  56. package/src/components/DatametriaTreeNode.vue +176 -172
  57. package/src/components/DatametriaUpload.vue +379 -361
  58. package/src/components/__tests__/DatametriaAlert.test.js +35 -35
  59. package/src/components/__tests__/DatametriaAlert.test.ts +190 -190
  60. package/src/components/__tests__/DatametriaAvatar.test.ts +151 -151
  61. package/src/components/__tests__/DatametriaBadge.test.js +29 -29
  62. package/src/components/__tests__/DatametriaBadge.test.ts +167 -167
  63. package/src/components/__tests__/DatametriaBreadcrumb.test.ts +187 -0
  64. package/src/components/__tests__/DatametriaButton.test.js +30 -30
  65. package/src/components/__tests__/DatametriaButton.test.ts +283 -283
  66. package/src/components/__tests__/DatametriaCard.test.ts +201 -201
  67. package/src/components/__tests__/DatametriaCheckbox.test.ts +204 -0
  68. package/src/components/__tests__/DatametriaChip.test.js +38 -38
  69. package/src/components/__tests__/DatametriaContainer.test.ts +52 -52
  70. package/src/components/__tests__/DatametriaDialog.test.ts +338 -0
  71. package/src/components/__tests__/DatametriaDivider.test.ts +54 -54
  72. package/src/components/__tests__/DatametriaDropdown.test.ts +357 -0
  73. package/src/components/__tests__/DatametriaEmpty.test.ts +261 -0
  74. package/src/components/__tests__/DatametriaFileUpload.test.ts +290 -290
  75. package/src/components/__tests__/DatametriaFloatingBar.test.ts +137 -137
  76. package/src/components/__tests__/DatametriaForm.test.ts +96 -0
  77. package/src/components/__tests__/DatametriaFormItem.test.ts +58 -0
  78. package/src/components/__tests__/DatametriaGrid.test.ts +31 -31
  79. package/src/components/__tests__/DatametriaInput.test.ts +72 -72
  80. package/src/components/__tests__/DatametriaMenu.test.ts +366 -366
  81. package/src/components/__tests__/DatametriaModal.test.ts +86 -86
  82. package/src/components/__tests__/DatametriaNavbar.test.js +48 -48
  83. package/src/components/__tests__/DatametriaNavbar.test.ts +203 -203
  84. package/src/components/__tests__/DatametriaPasswordInput.test.js +305 -305
  85. package/src/components/__tests__/DatametriaRadio.test.ts +195 -0
  86. package/src/components/__tests__/DatametriaSelect.test.ts +77 -77
  87. package/src/components/__tests__/DatametriaSidebar.test.ts +169 -169
  88. package/src/components/__tests__/DatametriaSlider.test.ts +261 -261
  89. package/src/components/__tests__/DatametriaSortableTable.test.js +168 -168
  90. package/src/components/__tests__/DatametriaSpinner.test.ts +156 -156
  91. package/src/components/__tests__/DatametriaSteps.test.ts +211 -0
  92. package/src/components/__tests__/DatametriaSwitch.test.ts +129 -0
  93. package/src/components/__tests__/DatametriaTabPane.test.ts +205 -0
  94. package/src/components/__tests__/DatametriaTable.test.ts +97 -97
  95. package/src/components/__tests__/DatametriaTabs.test.ts +232 -232
  96. package/src/components/__tests__/DatametriaToast.test.js +48 -48
  97. package/src/components/__tests__/DatametriaToast.test.ts +99 -99
  98. package/src/components/__tests__/DatametriaTree.test.ts +376 -0
  99. package/src/components/__tests__/index.test.ts +48 -0
  100. package/src/composables/useAccessibilityScale.ts +94 -94
  101. package/src/composables/useBreakpoints.ts +82 -82
  102. package/src/composables/useHapticFeedback.ts +439 -439
  103. package/src/composables/useRipple.ts +218 -218
  104. package/src/composables/useTheme.ts +5 -1
  105. package/src/index.ts +84 -84
  106. package/src/stories/Variants.stories.js +95 -95
  107. package/src/styles/design-tokens.css +623 -623
  108. package/src/theme/ThemeProvider.vue +96 -96
  109. package/src/theme/__tests__/ThemeProvider.test.ts +208 -208
  110. package/src/theme/__tests__/constants.test.ts +31 -31
  111. package/src/theme/__tests__/presets.test.ts +166 -166
  112. package/src/theme/__tests__/tokens.test.ts +155 -155
  113. package/src/theme/__tests__/types.test.ts +153 -153
  114. package/src/theme/__tests__/useTheme.test.ts +146 -146
  115. package/src/theme/constants.ts +14 -14
  116. package/src/theme/index.ts +12 -12
  117. package/src/theme/presets/datametria.ts +94 -94
  118. package/src/theme/presets/default.ts +94 -94
  119. package/src/theme/presets/index.ts +8 -8
  120. package/src/theme/tokens/colors.ts +28 -28
  121. package/src/theme/tokens/index.ts +47 -47
  122. package/src/theme/tokens/spacing.ts +21 -21
  123. package/src/theme/tokens/typography.ts +35 -35
  124. package/src/theme/types.ts +111 -111
  125. package/src/theme/useTheme.ts +28 -28
  126. package/src/types/index.ts +55 -55
@@ -1,605 +1,623 @@
1
- <template>
2
- <div class="dm-menu" :class="{ 'dm-menu--disabled': disabled }">
3
- <div
4
- ref="triggerRef"
5
- class="dm-menu__trigger"
6
- :aria-expanded="isOpen"
7
- :aria-haspopup="true"
8
- :aria-controls="menuId"
9
- @click="handleTriggerClick"
10
- @keydown="handleTriggerKeydown"
11
- >
12
- <slot name="trigger" :isOpen="isOpen" :toggle="toggle">
13
- <button
14
- type="button"
15
- class="dm-menu__button"
16
- :disabled="disabled"
17
- >
18
- {{ triggerText }}
19
- <svg
20
- class="dm-menu__chevron"
21
- :class="{ 'dm-menu__chevron--open': isOpen }"
22
- viewBox="0 0 20 20"
23
- fill="currentColor"
24
- >
25
- <path
26
- fill-rule="evenodd"
27
- d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
28
- clip-rule="evenodd"
29
- />
30
- </svg>
31
- </button>
32
- </slot>
33
- </div>
34
-
35
- <Teleport to="body">
36
- <Transition
37
- name="menu"
38
- @enter="onEnter"
39
- @leave="onLeave"
40
- >
41
- <div
42
- v-if="isOpen"
43
- ref="menuRef"
44
- :id="menuId"
45
- class="dm-menu__dropdown"
46
- :class="[
47
- `dm-menu__dropdown--${placement}`,
48
- { 'dm-menu__dropdown--full-width': fullWidth }
49
- ]"
50
- :style="menuStyle"
51
- role="menu"
52
- :aria-labelledby="triggerId"
53
- @keydown="handleMenuKeydown"
54
- @click="handleMenuClick"
55
- >
56
- <div class="dm-menu__content">
57
- <slot :close="close" :focusedIndex="focusedIndex">
58
- <div
59
- v-for="(item, index) in items"
60
- :key="item.key || index"
61
- class="dm-menu__item"
62
- :class="{
63
- 'dm-menu__item--focused': focusedIndex === index,
64
- 'dm-menu__item--disabled': item.disabled,
65
- 'dm-menu__item--divider': item.divider
66
- }"
67
- role="menuitem"
68
- :tabindex="item.disabled ? -1 : 0"
69
- :aria-disabled="item.disabled"
70
- @click="handleItemClick(item, index)"
71
- @mouseenter="focusedIndex = index"
72
- >
73
- <div v-if="item.divider" class="dm-menu__divider"></div>
74
- <template v-else>
75
- <div v-if="item.icon" class="dm-menu__item-icon">
76
- <component :is="item.icon" />
77
- </div>
78
- <div class="dm-menu__item-content">
79
- <div class="dm-menu__item-label">{{ item.label }}</div>
80
- <div v-if="item.description" class="dm-menu__item-description">
81
- {{ item.description }}
82
- </div>
83
- </div>
84
- <div v-if="item.shortcut" class="dm-menu__item-shortcut">
85
- {{ item.shortcut }}
86
- </div>
87
- </template>
88
- </div>
89
- </slot>
90
- </div>
91
- </div>
92
- </Transition>
93
- </Teleport>
94
-
95
- <!-- Backdrop for mobile -->
96
- <Teleport to="body">
97
- <Transition name="backdrop">
98
- <div
99
- v-if="isOpen && showBackdrop"
100
- class="dm-menu__backdrop"
101
- @click="close"
102
- ></div>
103
- </Transition>
104
- </Teleport>
105
- </div>
106
- </template>
107
-
108
- <script setup lang="ts">
109
- import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
110
-
111
- interface MenuItem {
112
- key?: string
113
- label?: string
114
- description?: string
115
- icon?: any
116
- shortcut?: string
117
- disabled?: boolean
118
- divider?: boolean
119
- action?: () => void
120
- }
121
-
122
- type Placement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end' | 'left' | 'right'
123
-
124
- interface Props {
125
- items?: MenuItem[]
126
- triggerText?: string
127
- placement?: Placement
128
- disabled?: boolean
129
- fullWidth?: boolean
130
- showBackdrop?: boolean
131
- closeOnItemClick?: boolean
132
- offset?: number
133
- }
134
-
135
- interface Emits {
136
- (e: 'open'): void
137
- (e: 'close'): void
138
- (e: 'item-click', item: MenuItem, index: number): void
139
- }
140
-
141
- const props = withDefaults(defineProps<Props>(), {
142
- items: () => [],
143
- triggerText: 'Menu',
144
- placement: 'bottom-start',
145
- showBackdrop: false,
146
- closeOnItemClick: true,
147
- offset: 4
148
- })
149
-
150
- const emit = defineEmits<Emits>()
151
-
152
- // Refs
153
- const triggerRef = ref<HTMLElement>()
154
- const menuRef = ref<HTMLElement>()
155
- const isOpen = ref(false)
156
- const focusedIndex = ref(-1)
157
-
158
- // Computed
159
- const menuId = computed(() => `dm-menu-${Math.random().toString(36).substr(2, 9)}`)
160
- const triggerId = computed(() => `dm-menu-trigger-${Math.random().toString(36).substr(2, 9)}`)
161
-
162
- const menuStyle = ref<Record<string, string>>({})
163
-
164
- // const enabledItems = computed(() =>
165
- // props.items.filter(item => !item.disabled && !item.divider)
166
- // )
167
-
168
- // Methods
169
- const calculatePosition = async () => {
170
- if (!triggerRef.value || !menuRef.value) return
171
-
172
- await nextTick()
173
-
174
- const trigger = triggerRef.value.getBoundingClientRect()
175
- const menu = menuRef.value.getBoundingClientRect()
176
- const viewport = {
177
- width: window.innerWidth,
178
- height: window.innerHeight
179
- }
180
-
181
- let top = 0
182
- let left = 0
183
-
184
- // Calculate base position
185
- switch (props.placement) {
186
- case 'bottom-start':
187
- top = trigger.bottom + props.offset
188
- left = trigger.left
189
- break
190
- case 'bottom-end':
191
- top = trigger.bottom + props.offset
192
- left = trigger.right - menu.width
193
- break
194
- case 'top-start':
195
- top = trigger.top - menu.height - props.offset
196
- left = trigger.left
197
- break
198
- case 'top-end':
199
- top = trigger.top - menu.height - props.offset
200
- left = trigger.right - menu.width
201
- break
202
- case 'left':
203
- top = trigger.top
204
- left = trigger.left - menu.width - props.offset
205
- break
206
- case 'right':
207
- top = trigger.top
208
- left = trigger.right + props.offset
209
- break
210
- }
211
-
212
- // Viewport boundary checks
213
- if (left < 8) {
214
- left = 8
215
- } else if (left + menu.width > viewport.width - 8) {
216
- left = viewport.width - menu.width - 8
217
- }
218
-
219
- if (top < 8) {
220
- top = 8
221
- } else if (top + menu.height > viewport.height - 8) {
222
- top = viewport.height - menu.height - 8
223
- }
224
-
225
- const width = props.fullWidth ? `${trigger.width}px` : 'auto'
226
-
227
- menuStyle.value = {
228
- position: 'fixed',
229
- top: `${top}px`,
230
- left: `${left}px`,
231
- width,
232
- zIndex: '9999'
233
- }
234
- }
235
-
236
- const open = async () => {
237
- if (props.disabled || isOpen.value) return
238
-
239
- isOpen.value = true
240
- focusedIndex.value = -1
241
- emit('open')
242
-
243
- await calculatePosition()
244
-
245
- // Focus first enabled item
246
- nextTick(() => {
247
- const firstEnabledIndex = props.items.findIndex(item => !item.disabled && !item.divider)
248
- if (firstEnabledIndex !== -1) {
249
- focusedIndex.value = firstEnabledIndex
250
- }
251
- })
252
- }
253
-
254
- const close = () => {
255
- if (!isOpen.value) return
256
-
257
- isOpen.value = false
258
- focusedIndex.value = -1
259
- emit('close')
260
-
261
- // Return focus to trigger
262
- nextTick(() => {
263
- triggerRef.value?.focus()
264
- })
265
- }
266
-
267
- const toggle = () => {
268
- if (isOpen.value) {
269
- close()
270
- } else {
271
- open()
272
- }
273
- }
274
-
275
- const handleTriggerClick = () => {
276
- toggle()
277
- }
278
-
279
- const handleTriggerKeydown = (event: KeyboardEvent) => {
280
- switch (event.key) {
281
- case 'Enter':
282
- case ' ':
283
- case 'ArrowDown':
284
- event.preventDefault()
285
- open()
286
- break
287
- case 'ArrowUp':
288
- event.preventDefault()
289
- open()
290
- // Focus last item
291
- nextTick(() => {
292
- const lastEnabledIndex = props.items.map((item, index) => ({ item, index }))
293
- .filter(({ item }) => !item.disabled && !item.divider)
294
- .pop()?.index ?? -1
295
- if (lastEnabledIndex !== -1) {
296
- focusedIndex.value = lastEnabledIndex
297
- }
298
- })
299
- break
300
- case 'Escape':
301
- close()
302
- break
303
- }
304
- }
305
-
306
- const handleMenuKeydown = (event: KeyboardEvent) => {
307
- switch (event.key) {
308
- case 'ArrowDown':
309
- event.preventDefault()
310
- focusNext()
311
- break
312
- case 'ArrowUp':
313
- event.preventDefault()
314
- focusPrevious()
315
- break
316
- case 'Enter':
317
- case ' ':
318
- event.preventDefault()
319
- if (focusedIndex.value !== -1) {
320
- const item = props.items[focusedIndex.value]
321
- handleItemClick(item, focusedIndex.value)
322
- }
323
- break
324
- case 'Escape':
325
- event.preventDefault()
326
- close()
327
- break
328
- case 'Tab':
329
- close()
330
- break
1
+ <template>
2
+ <div class="dm-menu" :class="{ 'dm-menu--disabled': disabled }">
3
+ <div
4
+ ref="triggerRef"
5
+ class="dm-menu__trigger"
6
+ :aria-expanded="isOpen"
7
+ :aria-haspopup="true"
8
+ :aria-controls="menuId"
9
+ @click="handleTriggerClick"
10
+ @keydown="handleTriggerKeydown"
11
+ >
12
+ <slot name="trigger" :isOpen="isOpen" :toggle="toggle">
13
+ <button
14
+ type="button"
15
+ class="dm-menu__button"
16
+ :disabled="disabled"
17
+ >
18
+ {{ triggerText }}
19
+ <svg
20
+ class="dm-menu__chevron"
21
+ :class="{ 'dm-menu__chevron--open': isOpen }"
22
+ viewBox="0 0 20 20"
23
+ fill="currentColor"
24
+ >
25
+ <path
26
+ fill-rule="evenodd"
27
+ d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
28
+ clip-rule="evenodd"
29
+ />
30
+ </svg>
31
+ </button>
32
+ </slot>
33
+ </div>
34
+
35
+ <Teleport to="body">
36
+ <Transition
37
+ name="menu"
38
+ @enter="onEnter"
39
+ @leave="onLeave"
40
+ >
41
+ <div
42
+ v-if="isOpen"
43
+ ref="menuRef"
44
+ :id="menuId"
45
+ class="dm-menu__dropdown"
46
+ :class="[
47
+ `dm-menu__dropdown--${placement}`,
48
+ { 'dm-menu__dropdown--full-width': fullWidth }
49
+ ]"
50
+ :style="menuStyle"
51
+ role="menu"
52
+ :aria-labelledby="triggerId"
53
+ @keydown="handleMenuKeydown"
54
+ @click="handleMenuClick"
55
+ >
56
+ <div class="dm-menu__content">
57
+ <slot :close="close" :focusedIndex="focusedIndex">
58
+ <div
59
+ v-for="(item, index) in items"
60
+ :key="item.key || index"
61
+ class="dm-menu__item"
62
+ :class="{
63
+ 'dm-menu__item--focused': focusedIndex === index,
64
+ 'dm-menu__item--disabled': item.disabled,
65
+ 'dm-menu__item--divider': item.divider
66
+ }"
67
+ role="menuitem"
68
+ :tabindex="item.disabled ? -1 : 0"
69
+ :aria-disabled="item.disabled"
70
+ @click="handleItemClick(item, index)"
71
+ @mouseenter="focusedIndex = index"
72
+ >
73
+ <div v-if="item.divider" class="dm-menu__divider"></div>
74
+ <template v-else>
75
+ <div v-if="item.icon" class="dm-menu__item-icon">
76
+ <component :is="item.icon" />
77
+ </div>
78
+ <div class="dm-menu__item-content">
79
+ <div class="dm-menu__item-label">{{ item.label }}</div>
80
+ <div v-if="item.description" class="dm-menu__item-description">
81
+ {{ item.description }}
82
+ </div>
83
+ </div>
84
+ <div v-if="item.shortcut" class="dm-menu__item-shortcut">
85
+ {{ item.shortcut }}
86
+ </div>
87
+ </template>
88
+ </div>
89
+ </slot>
90
+ </div>
91
+ </div>
92
+ </Transition>
93
+ </Teleport>
94
+
95
+ <!-- Backdrop for mobile -->
96
+ <Teleport to="body">
97
+ <Transition name="backdrop">
98
+ <div
99
+ v-if="isOpen && showBackdrop"
100
+ class="dm-menu__backdrop"
101
+ @click="close"
102
+ ></div>
103
+ </Transition>
104
+ </Teleport>
105
+ </div>
106
+ </template>
107
+
108
+ <script setup lang="ts">
109
+ import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
110
+
111
+ interface MenuItem {
112
+ key?: string
113
+ label?: string
114
+ description?: string
115
+ icon?: any
116
+ shortcut?: string
117
+ disabled?: boolean
118
+ divider?: boolean
119
+ action?: () => void
120
+ }
121
+
122
+ type Placement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end' | 'left' | 'right'
123
+
124
+ interface Props {
125
+ items?: MenuItem[]
126
+ triggerText?: string
127
+ placement?: Placement
128
+ disabled?: boolean
129
+ fullWidth?: boolean
130
+ showBackdrop?: boolean
131
+ closeOnItemClick?: boolean
132
+ offset?: number
133
+ }
134
+
135
+ interface Emits {
136
+ (e: 'open'): void
137
+ (e: 'close'): void
138
+ (e: 'item-click', item: MenuItem, index: number): void
139
+ }
140
+
141
+ const props = withDefaults(defineProps<Props>(), {
142
+ items: () => [],
143
+ triggerText: 'Menu',
144
+ placement: 'bottom-start',
145
+ showBackdrop: false,
146
+ closeOnItemClick: true,
147
+ offset: 4
148
+ })
149
+
150
+ const emit = defineEmits<Emits>()
151
+
152
+ // Refs
153
+ const triggerRef = ref<HTMLElement>()
154
+ const menuRef = ref<HTMLElement>()
155
+ const isOpen = ref(false)
156
+ const focusedIndex = ref(-1)
157
+
158
+ // Computed
159
+ const menuId = computed(() => `dm-menu-${Math.random().toString(36).substr(2, 9)}`)
160
+ const triggerId = computed(() => `dm-menu-trigger-${Math.random().toString(36).substr(2, 9)}`)
161
+
162
+ const menuStyle = ref<Record<string, string>>({})
163
+
164
+ // const enabledItems = computed(() =>
165
+ // props.items.filter(item => !item.disabled && !item.divider)
166
+ // )
167
+
168
+ // Methods
169
+ const calculatePosition = async () => {
170
+ if (!triggerRef.value || !menuRef.value) return
171
+
172
+ await nextTick()
173
+
174
+ const trigger = triggerRef.value.getBoundingClientRect()
175
+ const menu = menuRef.value.getBoundingClientRect()
176
+ const viewport = {
177
+ width: window.innerWidth,
178
+ height: window.innerHeight
179
+ }
180
+
181
+ let top = 0
182
+ let left = 0
183
+
184
+ // Calculate base position
185
+ switch (props.placement) {
186
+ case 'bottom-start':
187
+ top = trigger.bottom + props.offset
188
+ left = trigger.left
189
+ break
190
+ case 'bottom-end':
191
+ top = trigger.bottom + props.offset
192
+ left = trigger.right - menu.width
193
+ break
194
+ case 'top-start':
195
+ top = trigger.top - menu.height - props.offset
196
+ left = trigger.left
197
+ break
198
+ case 'top-end':
199
+ top = trigger.top - menu.height - props.offset
200
+ left = trigger.right - menu.width
201
+ break
202
+ case 'left':
203
+ top = trigger.top
204
+ left = trigger.left - menu.width - props.offset
205
+ break
206
+ case 'right':
207
+ top = trigger.top
208
+ left = trigger.right + props.offset
209
+ break
210
+ }
211
+
212
+ // Viewport boundary checks
213
+ if (left < 8) {
214
+ left = 8
215
+ } else if (left + menu.width > viewport.width - 8) {
216
+ left = viewport.width - menu.width - 8
217
+ }
218
+
219
+ if (top < 8) {
220
+ top = 8
221
+ } else if (top + menu.height > viewport.height - 8) {
222
+ top = viewport.height - menu.height - 8
223
+ }
224
+
225
+ const width = props.fullWidth ? `${trigger.width}px` : 'auto'
226
+
227
+ menuStyle.value = {
228
+ position: 'fixed',
229
+ top: `${top}px`,
230
+ left: `${left}px`,
231
+ width,
232
+ zIndex: '9999'
233
+ }
234
+ }
235
+
236
+ const open = async () => {
237
+ if (props.disabled || isOpen.value) return
238
+
239
+ isOpen.value = true
240
+ focusedIndex.value = -1
241
+ emit('open')
242
+
243
+ await calculatePosition()
244
+
245
+ // Focus first enabled item
246
+ nextTick(() => {
247
+ const firstEnabledIndex = props.items.findIndex(item => !item.disabled && !item.divider)
248
+ if (firstEnabledIndex !== -1) {
249
+ focusedIndex.value = firstEnabledIndex
250
+ }
251
+ })
252
+ }
253
+
254
+ const close = () => {
255
+ if (!isOpen.value) return
256
+
257
+ isOpen.value = false
258
+ focusedIndex.value = -1
259
+ emit('close')
260
+
261
+ // Return focus to trigger
262
+ nextTick(() => {
263
+ triggerRef.value?.focus()
264
+ })
265
+ }
266
+
267
+ const toggle = () => {
268
+ if (isOpen.value) {
269
+ close()
270
+ } else {
271
+ open()
272
+ }
273
+ }
274
+
275
+ const handleTriggerClick = () => {
276
+ toggle()
277
+ }
278
+
279
+ const handleTriggerKeydown = (event: KeyboardEvent) => {
280
+ switch (event.key) {
281
+ case 'Enter':
282
+ case ' ':
283
+ case 'ArrowDown':
284
+ event.preventDefault()
285
+ open()
286
+ break
287
+ case 'ArrowUp':
288
+ event.preventDefault()
289
+ open()
290
+ // Focus last item
291
+ nextTick(() => {
292
+ const lastEnabledIndex = props.items.map((item, index) => ({ item, index }))
293
+ .filter(({ item }) => !item.disabled && !item.divider)
294
+ .pop()?.index ?? -1
295
+ if (lastEnabledIndex !== -1) {
296
+ focusedIndex.value = lastEnabledIndex
297
+ }
298
+ })
299
+ break
300
+ case 'Escape':
301
+ close()
302
+ break
303
+ }
304
+ }
305
+
306
+ const handleMenuKeydown = (event: KeyboardEvent) => {
307
+ switch (event.key) {
308
+ case 'ArrowDown':
309
+ event.preventDefault()
310
+ focusNext()
311
+ break
312
+ case 'ArrowUp':
313
+ event.preventDefault()
314
+ focusPrevious()
315
+ break
316
+ case 'Enter':
317
+ case ' ':
318
+ event.preventDefault()
319
+ if (focusedIndex.value !== -1) {
320
+ const item = props.items[focusedIndex.value]
321
+ handleItemClick(item, focusedIndex.value)
322
+ }
323
+ break
324
+ case 'Escape':
325
+ event.preventDefault()
326
+ close()
327
+ break
328
+ case 'Tab':
329
+ close()
330
+ break
331
+ }
332
+ }
333
+
334
+ const focusNext = () => {
335
+ const enabledIndices = props.items
336
+ .map((item, index) => ({ item, index }))
337
+ .filter(({ item }) => !item.disabled && !item.divider)
338
+ .map(({ index }) => index)
339
+
340
+ if (enabledIndices.length === 0) return
341
+
342
+ const currentIndex = enabledIndices.indexOf(focusedIndex.value)
343
+ const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % enabledIndices.length
344
+ focusedIndex.value = enabledIndices[nextIndex]
345
+ }
346
+
347
+ const focusPrevious = () => {
348
+ const enabledIndices = props.items
349
+ .map((item, index) => ({ item, index }))
350
+ .filter(({ item }) => !item.disabled && !item.divider)
351
+ .map(({ index }) => index)
352
+
353
+ if (enabledIndices.length === 0) return
354
+
355
+ const currentIndex = enabledIndices.indexOf(focusedIndex.value)
356
+ const prevIndex = currentIndex === -1 ? enabledIndices.length - 1 : (currentIndex - 1 + enabledIndices.length) % enabledIndices.length
357
+ focusedIndex.value = enabledIndices[prevIndex]
358
+ }
359
+
360
+ const handleItemClick = (item: MenuItem, index: number) => {
361
+ if (item.disabled || item.divider) return
362
+
363
+ emit('item-click', item, index)
364
+
365
+ if (item.action) {
366
+ item.action()
367
+ }
368
+
369
+ if (props.closeOnItemClick) {
370
+ close()
371
+ }
372
+ }
373
+
374
+ const handleMenuClick = (event: Event) => {
375
+ event.stopPropagation()
376
+ }
377
+
378
+ const onEnter = () => {
379
+ calculatePosition()
380
+ }
381
+
382
+ const onLeave = () => {
383
+ menuStyle.value = {}
384
+ }
385
+
386
+ // Handle clicks outside
387
+ const handleClickOutside = (event: Event) => {
388
+ if (!isOpen.value) return
389
+
390
+ const target = event.target as Node
391
+ if (
392
+ triggerRef.value?.contains(target) ||
393
+ menuRef.value?.contains(target)
394
+ ) {
395
+ return
396
+ }
397
+
398
+ close()
399
+ }
400
+
401
+ // Handle window resize
402
+ const handleResize = () => {
403
+ if (isOpen.value) {
404
+ calculatePosition()
405
+ }
406
+ }
407
+
408
+ // Lifecycle
409
+ onMounted(() => {
410
+ document.addEventListener('click', handleClickOutside)
411
+ window.addEventListener('resize', handleResize)
412
+ })
413
+
414
+ onUnmounted(() => {
415
+ document.removeEventListener('click', handleClickOutside)
416
+ window.removeEventListener('resize', handleResize)
417
+ })
418
+
419
+ // Watch for items changes
420
+ watch(() => props.items, () => {
421
+ if (focusedIndex.value >= props.items.length) {
422
+ focusedIndex.value = -1
423
+ }
424
+ })
425
+
426
+ // Expose methods
427
+ defineExpose({
428
+ open,
429
+ close,
430
+ toggle,
431
+ isOpen: computed(() => isOpen.value)
432
+ })
433
+ </script>
434
+
435
+ <style scoped>
436
+ .dm-menu {
437
+ @apply relative inline-block;
438
+ }
439
+
440
+ .dm-menu--disabled {
441
+ @apply opacity-60 cursor-not-allowed;
442
+ }
443
+
444
+ .dm-menu__trigger {
445
+ @apply inline-block;
446
+ }
447
+
448
+ .dm-menu__button {
449
+ @apply inline-flex items-center justify-between text-sm font-medium;
450
+ @apply focus:outline-none disabled:cursor-not-allowed;
451
+
452
+ padding: var(--dm-spacing-2, 0.5rem) var(--dm-spacing-4, 1rem);
453
+ background-color: var(--dm-neutral-50, #ffffff);
454
+ border: 1px solid var(--dm-neutral-300, #d1d5db);
455
+ border-radius: var(--dm-radius-md, 0.375rem);
456
+ color: var(--dm-neutral-900, #111827);
457
+ font-size: var(--dm-font-size-sm, 0.875rem);
458
+ font-weight: var(--dm-font-weight-medium, 500);
459
+ box-shadow: var(--dm-shadow-sm, 0 1px 2px 0 rgba(0, 0, 0, 0.05));
460
+ transition: all 0.2s ease;
461
+ }
462
+
463
+ .dm-menu__button:hover:not(:disabled) {
464
+ background-color: color-mix(in srgb, var(--dm-neutral-50, #ffffff) 95%, var(--dm-neutral-900, #111827) 5%);
465
+ }
466
+
467
+ .dm-menu__button:focus {
468
+ border-color: var(--dm-primary, #0072CE);
469
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--dm-primary, #0072CE) 20%, transparent);
470
+ }
471
+
472
+ .dm-menu__button:disabled {
473
+ opacity: 0.5;
474
+ }
475
+
476
+ .dm-menu__chevron {
477
+ @apply w-4 h-4;
478
+ margin-left: var(--dm-spacing-2, 0.5rem);
479
+ transition: transform 0.2s ease;
480
+ }
481
+
482
+ .dm-menu__chevron--open {
483
+ @apply transform rotate-180;
484
+ }
485
+
486
+ .dm-menu__dropdown {
487
+ @apply min-w-48 max-w-xs;
488
+
489
+ background-color: var(--dm-neutral-50, #ffffff);
490
+ border: 1px solid var(--dm-neutral-200, #e5e7eb);
491
+ border-radius: var(--dm-radius-md, 0.375rem);
492
+ box-shadow: var(--dm-shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1));
493
+ }
494
+
495
+ .dm-menu__content {
496
+ padding: var(--dm-spacing-1, 0.25rem) 0;
497
+ }
498
+
499
+ .dm-menu__item {
500
+ @apply flex items-center cursor-pointer focus:outline-none;
501
+
502
+ padding: var(--dm-spacing-2, 0.5rem) var(--dm-spacing-4, 1rem);
503
+ color: var(--dm-neutral-900, #111827);
504
+ font-size: var(--dm-font-size-sm, 0.875rem);
505
+ transition: background-color 0.15s ease;
506
+ }
507
+
508
+ .dm-menu__item:hover:not(.dm-menu__item--disabled) {
509
+ background-color: var(--dm-neutral-100, #f3f4f6);
510
+ }
511
+
512
+ .dm-menu__item--focused {
513
+ background-color: var(--dm-neutral-100, #f3f4f6);
514
+ }
515
+
516
+ .dm-menu__item--disabled {
517
+ opacity: 0.5;
518
+ cursor: not-allowed;
519
+ }
520
+
521
+ .dm-menu__item--disabled:hover,
522
+ .dm-menu__item--disabled:focus {
523
+ background-color: transparent;
524
+ }
525
+
526
+ .dm-menu__item--divider {
527
+ @apply p-0 cursor-default;
528
+ }
529
+
530
+ .dm-menu__divider {
531
+ border-top: 1px solid var(--dm-neutral-200, #e5e7eb);
532
+ margin: var(--dm-spacing-1, 0.25rem) 0;
533
+ }
534
+
535
+ .dm-menu__item-icon {
536
+ @apply w-4 h-4 mr-3 flex-shrink-0;
537
+ }
538
+
539
+ .dm-menu__item-content {
540
+ @apply flex-1 min-w-0;
541
+ }
542
+
543
+ .dm-menu__item-label {
544
+ @apply font-medium;
545
+ }
546
+
547
+ .dm-menu__item-description {
548
+ color: var(--dm-neutral-500, #6b7280);
549
+ font-size: var(--dm-font-size-xs, 0.75rem);
550
+ margin-top: 0.125rem;
551
+ }
552
+
553
+ .dm-menu__item-shortcut {
554
+ @apply flex-shrink-0;
555
+ color: var(--dm-neutral-400, #9ca3af);
556
+ font-size: var(--dm-font-size-xs, 0.75rem);
557
+ margin-left: var(--dm-spacing-4, 1rem);
558
+ }
559
+
560
+ .dm-menu__backdrop {
561
+ @apply fixed inset-0 bg-black bg-opacity-25 z-40;
562
+ }
563
+
564
+ /* Transitions */
565
+ .menu-enter-active,
566
+ .menu-leave-active {
567
+ transition: opacity 0.15s ease, transform 0.15s ease;
568
+ }
569
+
570
+ .menu-enter-from,
571
+ .menu-leave-to {
572
+ opacity: 0;
573
+ transform: scale(0.95) translateY(-10px);
574
+ }
575
+
576
+ .backdrop-enter-active,
577
+ .backdrop-leave-active {
578
+ transition: opacity 0.15s ease;
579
+ }
580
+
581
+ .backdrop-enter-from,
582
+ .backdrop-leave-to {
583
+ opacity: 0;
584
+ }
585
+
586
+ /* Reduced motion support */
587
+ @media (prefers-reduced-motion: reduce) {
588
+ .dm-menu__chevron,
589
+ .dm-menu__item,
590
+ .menu-enter-active,
591
+ .menu-leave-active,
592
+ .backdrop-enter-active,
593
+ .backdrop-leave-active {
594
+ transition: none;
595
+ }
596
+ }
597
+
598
+ /* Mobile optimizations */
599
+ @media (max-width: 640px) {
600
+ .dm-menu__dropdown {
601
+ @apply max-w-none;
602
+ width: calc(100vw - 2rem);
603
+ }
604
+ }
605
+
606
+ /* Dark Mode Support - Hybrid Approach */
607
+
608
+ /* Fallback automático (sem JS) */
609
+ @media (prefers-color-scheme: dark) {
610
+ .dm-menu {
611
+ background: var(--dm-bg-color-dark, #1e1e1e);
612
+ color: var(--dm-text-primary-dark, #e0e0e0);
613
+ border-color: var(--dm-border-color-dark, #404040);
331
614
  }
332
615
  }
333
616
 
334
- const focusNext = () => {
335
- const enabledIndices = props.items
336
- .map((item, index) => ({ item, index }))
337
- .filter(({ item }) => !item.disabled && !item.divider)
338
- .map(({ index }) => index)
339
-
340
- if (enabledIndices.length === 0) return
341
-
342
- const currentIndex = enabledIndices.indexOf(focusedIndex.value)
343
- const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % enabledIndices.length
344
- focusedIndex.value = enabledIndices[nextIndex]
345
- }
346
-
347
- const focusPrevious = () => {
348
- const enabledIndices = props.items
349
- .map((item, index) => ({ item, index }))
350
- .filter(({ item }) => !item.disabled && !item.divider)
351
- .map(({ index }) => index)
352
-
353
- if (enabledIndices.length === 0) return
354
-
355
- const currentIndex = enabledIndices.indexOf(focusedIndex.value)
356
- const prevIndex = currentIndex === -1 ? enabledIndices.length - 1 : (currentIndex - 1 + enabledIndices.length) % enabledIndices.length
357
- focusedIndex.value = enabledIndices[prevIndex]
358
- }
359
-
360
- const handleItemClick = (item: MenuItem, index: number) => {
361
- if (item.disabled || item.divider) return
362
-
363
- emit('item-click', item, index)
364
-
365
- if (item.action) {
366
- item.action()
367
- }
368
-
369
- if (props.closeOnItemClick) {
370
- close()
371
- }
372
- }
373
-
374
- const handleMenuClick = (event: Event) => {
375
- event.stopPropagation()
376
- }
377
-
378
- const onEnter = () => {
379
- calculatePosition()
380
- }
381
-
382
- const onLeave = () => {
383
- menuStyle.value = {}
384
- }
385
-
386
- // Handle clicks outside
387
- const handleClickOutside = (event: Event) => {
388
- if (!isOpen.value) return
389
-
390
- const target = event.target as Node
391
- if (
392
- triggerRef.value?.contains(target) ||
393
- menuRef.value?.contains(target)
394
- ) {
395
- return
396
- }
397
-
398
- close()
399
- }
400
-
401
- // Handle window resize
402
- const handleResize = () => {
403
- if (isOpen.value) {
404
- calculatePosition()
405
- }
406
- }
407
-
408
- // Lifecycle
409
- onMounted(() => {
410
- document.addEventListener('click', handleClickOutside)
411
- window.addEventListener('resize', handleResize)
412
- })
413
-
414
- onUnmounted(() => {
415
- document.removeEventListener('click', handleClickOutside)
416
- window.removeEventListener('resize', handleResize)
417
- })
418
-
419
- // Watch for items changes
420
- watch(() => props.items, () => {
421
- if (focusedIndex.value >= props.items.length) {
422
- focusedIndex.value = -1
423
- }
424
- })
425
-
426
- // Expose methods
427
- defineExpose({
428
- open,
429
- close,
430
- toggle,
431
- isOpen: computed(() => isOpen.value)
432
- })
433
- </script>
434
-
435
- <style scoped>
436
- .dm-menu {
437
- @apply relative inline-block;
438
- }
439
-
440
- .dm-menu--disabled {
441
- @apply opacity-60 cursor-not-allowed;
442
- }
443
-
444
- .dm-menu__trigger {
445
- @apply inline-block;
446
- }
447
-
448
- .dm-menu__button {
449
- @apply inline-flex items-center justify-between text-sm font-medium;
450
- @apply focus:outline-none disabled:cursor-not-allowed;
451
-
452
- padding: var(--dm-spacing-2, 0.5rem) var(--dm-spacing-4, 1rem);
453
- background-color: var(--dm-neutral-50, #ffffff);
454
- border: 1px solid var(--dm-neutral-300, #d1d5db);
455
- border-radius: var(--dm-radius-md, 0.375rem);
456
- color: var(--dm-neutral-900, #111827);
457
- font-size: var(--dm-font-size-sm, 0.875rem);
458
- font-weight: var(--dm-font-weight-medium, 500);
459
- box-shadow: var(--dm-shadow-sm, 0 1px 2px 0 rgba(0, 0, 0, 0.05));
460
- transition: all 0.2s ease;
461
- }
462
-
463
- .dm-menu__button:hover:not(:disabled) {
464
- background-color: color-mix(in srgb, var(--dm-neutral-50, #ffffff) 95%, var(--dm-neutral-900, #111827) 5%);
465
- }
466
-
467
- .dm-menu__button:focus {
468
- border-color: var(--dm-primary, #0072CE);
469
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--dm-primary, #0072CE) 20%, transparent);
470
- }
471
-
472
- .dm-menu__button:disabled {
473
- opacity: 0.5;
474
- }
475
-
476
- .dm-menu__chevron {
477
- @apply w-4 h-4;
478
- margin-left: var(--dm-spacing-2, 0.5rem);
479
- transition: transform 0.2s ease;
480
- }
481
-
482
- .dm-menu__chevron--open {
483
- @apply transform rotate-180;
484
- }
485
-
486
- .dm-menu__dropdown {
487
- @apply min-w-48 max-w-xs;
488
-
489
- background-color: var(--dm-neutral-50, #ffffff);
490
- border: 1px solid var(--dm-neutral-200, #e5e7eb);
491
- border-radius: var(--dm-radius-md, 0.375rem);
492
- box-shadow: var(--dm-shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1));
493
- }
494
-
495
- .dm-menu__content {
496
- padding: var(--dm-spacing-1, 0.25rem) 0;
497
- }
498
-
499
- .dm-menu__item {
500
- @apply flex items-center cursor-pointer focus:outline-none;
501
-
502
- padding: var(--dm-spacing-2, 0.5rem) var(--dm-spacing-4, 1rem);
503
- color: var(--dm-neutral-900, #111827);
504
- font-size: var(--dm-font-size-sm, 0.875rem);
505
- transition: background-color 0.15s ease;
506
- }
507
-
508
- .dm-menu__item:hover:not(.dm-menu__item--disabled) {
509
- background-color: var(--dm-neutral-100, #f3f4f6);
510
- }
511
-
512
- .dm-menu__item--focused {
513
- background-color: var(--dm-neutral-100, #f3f4f6);
514
- }
515
-
516
- .dm-menu__item--disabled {
517
- opacity: 0.5;
518
- cursor: not-allowed;
519
- }
520
-
521
- .dm-menu__item--disabled:hover,
522
- .dm-menu__item--disabled:focus {
523
- background-color: transparent;
524
- }
525
-
526
- .dm-menu__item--divider {
527
- @apply p-0 cursor-default;
528
- }
529
-
530
- .dm-menu__divider {
531
- border-top: 1px solid var(--dm-neutral-200, #e5e7eb);
532
- margin: var(--dm-spacing-1, 0.25rem) 0;
533
- }
534
-
535
- .dm-menu__item-icon {
536
- @apply w-4 h-4 mr-3 flex-shrink-0;
537
- }
538
-
539
- .dm-menu__item-content {
540
- @apply flex-1 min-w-0;
541
- }
542
-
543
- .dm-menu__item-label {
544
- @apply font-medium;
545
- }
546
-
547
- .dm-menu__item-description {
548
- color: var(--dm-neutral-500, #6b7280);
549
- font-size: var(--dm-font-size-xs, 0.75rem);
550
- margin-top: 0.125rem;
551
- }
552
-
553
- .dm-menu__item-shortcut {
554
- @apply flex-shrink-0;
555
- color: var(--dm-neutral-400, #9ca3af);
556
- font-size: var(--dm-font-size-xs, 0.75rem);
557
- margin-left: var(--dm-spacing-4, 1rem);
558
- }
559
-
560
- .dm-menu__backdrop {
561
- @apply fixed inset-0 bg-black bg-opacity-25 z-40;
562
- }
563
-
564
- /* Transitions */
565
- .menu-enter-active,
566
- .menu-leave-active {
567
- transition: opacity 0.15s ease, transform 0.15s ease;
568
- }
569
-
570
- .menu-enter-from,
571
- .menu-leave-to {
572
- opacity: 0;
573
- transform: scale(0.95) translateY(-10px);
574
- }
575
-
576
- .backdrop-enter-active,
577
- .backdrop-leave-active {
578
- transition: opacity 0.15s ease;
579
- }
580
-
581
- .backdrop-enter-from,
582
- .backdrop-leave-to {
583
- opacity: 0;
584
- }
585
-
586
- /* Reduced motion support */
587
- @media (prefers-reduced-motion: reduce) {
588
- .dm-menu__chevron,
589
- .dm-menu__item,
590
- .menu-enter-active,
591
- .menu-leave-active,
592
- .backdrop-enter-active,
593
- .backdrop-leave-active {
594
- transition: none;
595
- }
596
- }
597
-
598
- /* Mobile optimizations */
599
- @media (max-width: 640px) {
600
- .dm-menu__dropdown {
601
- @apply max-w-none;
602
- width: calc(100vw - 2rem);
603
- }
617
+ /* Controle manual via useTheme() */
618
+ [data-theme="dark"] .dm-menu {
619
+ background: var(--dm-bg-color-dark, #1e1e1e);
620
+ color: var(--dm-text-primary-dark, #e0e0e0);
621
+ border-color: var(--dm-border-color-dark, #404040);
604
622
  }
605
623
  </style>