@datametria/vue-components 1.2.0 → 2.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 (97) hide show
  1. package/README.md +548 -657
  2. package/dist/index.es.js +2353 -1364
  3. package/dist/index.umd.js +10 -10
  4. package/dist/vue-components.css +1 -1
  5. package/package.json +102 -98
  6. package/src/components/DatametriaAlert.vue +137 -137
  7. package/src/components/DatametriaAutocomplete.vue +184 -138
  8. package/src/components/DatametriaAvatar.vue +177 -33
  9. package/src/components/DatametriaBadge.vue +98 -98
  10. package/src/components/DatametriaBreadcrumb.vue +21 -21
  11. package/src/components/DatametriaButton.vue +177 -165
  12. package/src/components/DatametriaCard.vue +12 -12
  13. package/src/components/DatametriaCheckbox.vue +8 -8
  14. package/src/components/DatametriaChip.vue +145 -149
  15. package/src/components/DatametriaContainer.vue +4 -4
  16. package/src/components/DatametriaDatePicker.vue +686 -68
  17. package/src/components/DatametriaDivider.vue +13 -13
  18. package/src/components/DatametriaFileUpload.vue +272 -140
  19. package/src/components/DatametriaGrid.vue +3 -3
  20. package/src/components/DatametriaInput.vue +15 -15
  21. package/src/components/DatametriaMenu.vue +604 -619
  22. package/src/components/DatametriaModal.vue +16 -16
  23. package/src/components/DatametriaNavbar.vue +230 -252
  24. package/src/components/DatametriaPasswordInput.vue +430 -0
  25. package/src/components/DatametriaProgress.vue +18 -18
  26. package/src/components/DatametriaRadio.vue +20 -20
  27. package/src/components/DatametriaSelect.vue +15 -15
  28. package/src/components/DatametriaSkeleton.vue +243 -239
  29. package/src/components/DatametriaSlider.vue +395 -407
  30. package/src/components/DatametriaSortableTable.vue +585 -0
  31. package/src/components/DatametriaSpinner.vue +7 -7
  32. package/src/components/DatametriaSwitch.vue +16 -16
  33. package/src/components/DatametriaTable.vue +14 -14
  34. package/src/components/DatametriaTextarea.vue +28 -28
  35. package/src/components/DatametriaTimePicker.vue +285 -285
  36. package/src/components/DatametriaToast.vue +176 -176
  37. package/src/components/DatametriaTooltip.vue +408 -408
  38. package/src/components/__tests__/DatametriaAlert.test.js +35 -35
  39. package/src/components/__tests__/DatametriaAlert.test.ts +190 -0
  40. package/src/components/__tests__/DatametriaAutocomplete.test.ts +180 -0
  41. package/src/components/__tests__/DatametriaAvatar.test.ts +152 -0
  42. package/src/components/__tests__/DatametriaBadge.test.js +29 -29
  43. package/src/components/__tests__/DatametriaBadge.test.ts +167 -0
  44. package/src/components/__tests__/DatametriaBreadcrumb.test.ts +75 -0
  45. package/src/components/__tests__/DatametriaButton.test.js +30 -30
  46. package/src/components/__tests__/DatametriaButton.test.ts +283 -0
  47. package/src/components/__tests__/DatametriaCard.test.ts +201 -0
  48. package/src/components/__tests__/DatametriaCheckbox.test.ts +47 -0
  49. package/src/components/__tests__/DatametriaChip.test.js +38 -38
  50. package/src/components/__tests__/DatametriaContainer.test.ts +52 -0
  51. package/src/components/__tests__/DatametriaDatePicker.test.ts +234 -0
  52. package/src/components/__tests__/DatametriaDivider.test.ts +54 -0
  53. package/src/components/__tests__/DatametriaFileUpload.test.ts +291 -0
  54. package/src/components/__tests__/DatametriaGrid.test.ts +31 -0
  55. package/src/components/__tests__/DatametriaInput.test.ts +72 -0
  56. package/src/components/__tests__/DatametriaMenu.test.ts +366 -0
  57. package/src/components/__tests__/DatametriaModal.test.ts +86 -0
  58. package/src/components/__tests__/DatametriaNavbar.test.js +48 -48
  59. package/src/components/__tests__/DatametriaNavbar.test.ts +203 -0
  60. package/src/components/__tests__/DatametriaPasswordInput.test.js +305 -0
  61. package/src/components/__tests__/DatametriaProgress.test.ts +90 -0
  62. package/src/components/__tests__/DatametriaRadio.test.ts +77 -0
  63. package/src/components/__tests__/DatametriaSelect.test.ts +77 -0
  64. package/src/components/__tests__/DatametriaSlider.test.ts +261 -0
  65. package/src/components/__tests__/DatametriaSortableTable.test.js +168 -0
  66. package/src/components/__tests__/DatametriaSpinner.test.ts +156 -0
  67. package/src/components/__tests__/DatametriaSwitch.test.ts +64 -0
  68. package/src/components/__tests__/DatametriaTable.test.ts +97 -0
  69. package/src/components/__tests__/DatametriaTextarea.test.ts +66 -0
  70. package/src/components/__tests__/DatametriaToast.test.js +48 -48
  71. package/src/components/__tests__/DatametriaToast.test.ts +99 -0
  72. package/src/composables/useAccessibilityScale.ts +94 -94
  73. package/src/composables/useBreakpoints.ts +82 -82
  74. package/src/composables/useHapticFeedback.ts +439 -439
  75. package/src/composables/useRipple.ts +218 -218
  76. package/src/index.ts +68 -61
  77. package/src/stories/Variants.stories.js +95 -95
  78. package/src/styles/design-tokens.css +623 -623
  79. package/src/theme/ThemeProvider.vue +96 -0
  80. package/src/theme/__tests__/ThemeProvider.test.ts +208 -0
  81. package/src/theme/__tests__/constants.test.ts +31 -0
  82. package/src/theme/__tests__/presets.test.ts +166 -0
  83. package/src/theme/__tests__/tokens.test.ts +155 -0
  84. package/src/theme/__tests__/types.test.ts +153 -0
  85. package/src/theme/__tests__/useTheme.test.ts +146 -0
  86. package/src/theme/constants.ts +14 -0
  87. package/src/theme/index.ts +12 -0
  88. package/src/theme/presets/datametria.ts +94 -0
  89. package/src/theme/presets/default.ts +94 -0
  90. package/src/theme/presets/index.ts +8 -0
  91. package/src/theme/tokens/colors.ts +28 -0
  92. package/src/theme/tokens/index.ts +47 -0
  93. package/src/theme/tokens/spacing.ts +21 -0
  94. package/src/theme/tokens/typography.ts +35 -0
  95. package/src/theme/types.ts +111 -0
  96. package/src/theme/useTheme.ts +28 -0
  97. package/src/types/index.ts +19 -0
@@ -1,620 +1,605 @@
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 px-4 py-2 text-sm font-medium;
450
- @apply bg-white border border-gray-300 rounded-md shadow-sm;
451
- @apply hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500;
452
- @apply disabled:opacity-50 disabled:cursor-not-allowed;
453
- @apply transition-colors duration-200;
454
-
455
- background-color: var(--dm-bg-primary, #ffffff);
456
- border-color: var(--dm-gray-300, #d1d5db);
457
- color: var(--dm-text-primary);
458
- border-radius: var(--dm-radius);
459
- transition: var(--dm-transition);
460
- }
461
-
462
- .dm-menu__button:focus {
463
- box-shadow: var(--dm-focus-ring);
464
- border-color: var(--dm-primary);
465
- }
466
-
467
- [data-theme="dark"] .dm-menu__button {
468
- background-color: var(--dm-bg-secondary);
469
- border-color: var(--dm-gray-600, #4b5563);
470
- color: var(--dm-text-primary);
471
- }
472
-
473
- .dm-menu__chevron {
474
- @apply w-4 h-4 ml-2 transition-transform duration-200;
475
- transition: var(--dm-transition);
476
- }
477
-
478
- .dm-menu__chevron--open {
479
- @apply transform rotate-180;
480
- }
481
-
482
- .dm-menu__dropdown {
483
- @apply bg-white border border-gray-200 rounded-md shadow-lg;
484
- @apply min-w-48 max-w-xs;
485
-
486
- background-color: var(--dm-bg-primary, #ffffff);
487
- border-color: var(--dm-gray-200, #e5e7eb);
488
- border-radius: var(--dm-radius);
489
- }
490
-
491
- [data-theme="dark"] .dm-menu__dropdown {
492
- background-color: var(--dm-bg-secondary);
493
- border-color: var(--dm-gray-600, #4b5563);
494
- }
495
-
496
- .dm-menu__content {
497
- @apply py-1;
498
- }
499
-
500
- .dm-menu__item {
501
- @apply flex items-center px-4 py-2 text-sm cursor-pointer;
502
- @apply hover:bg-gray-100 focus:bg-gray-100 focus:outline-none;
503
- @apply transition-colors duration-150;
504
-
505
- color: var(--dm-text-primary);
506
- transition: var(--transition-fast);
507
- }
508
-
509
- .dm-menu__item--focused {
510
- @apply bg-gray-100;
511
- background-color: var(--dm-gray-100, #f3f4f6);
512
- }
513
-
514
- .dm-menu__item--disabled {
515
- @apply opacity-50 cursor-not-allowed;
516
- }
517
-
518
- .dm-menu__item--disabled:hover,
519
- .dm-menu__item--disabled:focus {
520
- @apply bg-transparent;
521
- }
522
-
523
- [data-theme="dark"] .dm-menu__item {
524
- color: var(--dm-text-primary);
525
- }
526
-
527
- [data-theme="dark"] .dm-menu__item:hover,
528
- [data-theme="dark"] .dm-menu__item--focused {
529
- background-color: var(--dm-gray-700, #374151);
530
- }
531
-
532
- .dm-menu__item--divider {
533
- @apply p-0 cursor-default;
534
- }
535
-
536
- .dm-menu__divider {
537
- @apply border-t border-gray-200 my-1;
538
- border-color: var(--dm-gray-200, #e5e7eb);
539
- }
540
-
541
- [data-theme="dark"] .dm-menu__divider {
542
- border-color: var(--dm-gray-600, #4b5563);
543
- }
544
-
545
- .dm-menu__item-icon {
546
- @apply w-4 h-4 mr-3 flex-shrink-0;
547
- }
548
-
549
- .dm-menu__item-content {
550
- @apply flex-1 min-w-0;
551
- }
552
-
553
- .dm-menu__item-label {
554
- @apply font-medium;
555
- }
556
-
557
- .dm-menu__item-description {
558
- @apply text-xs text-gray-500 mt-0.5;
559
- color: var(--dm-gray-500, #6b7280);
560
- }
561
-
562
- [data-theme="dark"] .dm-menu__item-description {
563
- color: var(--dm-text-secondary);
564
- }
565
-
566
- .dm-menu__item-shortcut {
567
- @apply text-xs text-gray-400 ml-4 flex-shrink-0;
568
- color: var(--dm-gray-400, #9ca3af);
569
- }
570
-
571
- [data-theme="dark"] .dm-menu__item-shortcut {
572
- color: var(--dm-gray-500, #6b7280);
573
- }
574
-
575
- .dm-menu__backdrop {
576
- @apply fixed inset-0 bg-black bg-opacity-25 z-40;
577
- }
578
-
579
- /* Transitions */
580
- .menu-enter-active,
581
- .menu-leave-active {
582
- transition: opacity var(--transition-fast), transform var(--transition-fast);
583
- }
584
-
585
- .menu-enter-from,
586
- .menu-leave-to {
587
- opacity: 0;
588
- transform: scale(0.95) translateY(-10px);
589
- }
590
-
591
- .backdrop-enter-active,
592
- .backdrop-leave-active {
593
- transition: opacity var(--transition-fast);
594
- }
595
-
596
- .backdrop-enter-from,
597
- .backdrop-leave-to {
598
- opacity: 0;
599
- }
600
-
601
- /* Reduced motion support */
602
- @media (prefers-reduced-motion: reduce) {
603
- .dm-menu__chevron,
604
- .dm-menu__item,
605
- .menu-enter-active,
606
- .menu-leave-active,
607
- .backdrop-enter-active,
608
- .backdrop-leave-active {
609
- transition: none;
610
- }
611
- }
612
-
613
- /* Mobile optimizations */
614
- @media (max-width: 640px) {
615
- .dm-menu__dropdown {
616
- @apply max-w-none;
617
- width: calc(100vw - 2rem);
618
- }
619
- }
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
+ }
620
605
  </style>