vue-float-menu
Version:
smart floating menu for Vue 3
1 lines • 182 kB
Source Map (JSON)
{"version":3,"file":"vue-float-menu.cjs","sources":["../src/utils/index.ts","../src/components/icons/MenuIcon.vue","../src/components/icons/MenuIcon.vue?vue&type=template&id=1b54617d&lang.js","../src/components/icons/XIcon.vue","../src/components/icons/XIcon.vue?vue&type=template&id=b39c2c48&lang.js","../src/components/icons/ChevRightIcon.vue","../src/components/icons/ChevRightIcon.vue?vue&type=template&id=42d56c4a&lang.js","../src/components/icons/PlusIcon.vue","../src/components/icons/PlusIcon.vue?vue&type=template&id=6bb92290&lang.js","../src/components/icons/MinusIcon.vue","../src/components/icons/MinusIcon.vue?vue&type=template&id=82ad6f58&lang.js","../src/types/index.ts","../src/components/composables/useTouchOptimizations.ts","../src/components/composables/useBundleOptimizations.ts","../src/components/Menu.vue","../src/components/composables/useMenuState.ts","../src/components/Menu.vue?vue&type=template&id=9bcc0be2&lang.js","../node_modules/.pnpm/style-inject@0.3.0/node_modules/style-inject/dist/style-inject.es.js","../src/components/props.ts","../src/components/index.vue","../src/components/index.vue?vue&type=template&id=47323bf2&lang.js","../src/index.ts","../src/components/composables/useAnimations.ts","../src/components/composables/usePerformanceOptimizations.ts","../src/components/composables/useErrorHandling.ts"],"sourcesContent":["import { Position, UtilsType } from '../types/index';\n\nconst utils: UtilsType = {\n setupMenuOrientation: (head, content, headDimension, menuDimension) => {\n const { top, bottom } = head.getBoundingClientRect();\n const left = Math.round((content.clientWidth - headDimension) / 2);\n const menuHeight = content.clientHeight;\n let newOrientation = 'bottom';\n const MENU_SPACING = 15;\n let newMenuStyle = null;\n\n // flip to bottom if there is not enough space on top\n if (menuHeight > top) {\n newMenuStyle = {\n top: `${headDimension + MENU_SPACING}px`,\n left: `-${left}px`,\n };\n newOrientation = 'top';\n } else if (window.innerHeight - bottom < menuHeight) {\n newMenuStyle = {\n bottom: `${headDimension + MENU_SPACING}px`,\n left: `-${left}px`,\n };\n newOrientation = 'bottom';\n } else {\n newMenuStyle = {\n top: `${headDimension + MENU_SPACING}px`,\n left: `-${left}px`,\n };\n newOrientation = 'top';\n }\n\n return Object.assign({}, newMenuStyle, {\n 'min-height': `${menuDimension.height}px`,\n width: `${menuDimension.width}px`,\n newOrientation,\n });\n },\n setupMenuPosition: (element, position, flipOnEdges, menuContainer) => {\n const { top, bottom, left, right } = element.getBoundingClientRect();\n const { innerWidth: screenWidth, innerHeight: screenHeight } = window;\n const menuContWidth = menuContainer.clientWidth;\n const menuContHalfWidth = Math.ceil(menuContWidth / 2);\n let newPosition: Position | null = null;\n\n let flipMenu = false;\n let reveal = false;\n\n if (flipOnEdges) {\n flipMenu = false;\n }\n\n // reposition if the menu head goes below the bottom of the viewport\n if (bottom > screenHeight) {\n newPosition = {\n left: position.left,\n top: position.top - (bottom - screenHeight),\n };\n reveal = true;\n }\n\n // reposition if the menu head goes above the bottom of the viewport\n if (top < 0) {\n newPosition = {\n left: position.left,\n top: position.top + Math.abs(top),\n };\n reveal = true;\n }\n\n // reposition if the menu head goes beyond the left side of the viewport\n if (left < 0 || left < menuContHalfWidth) {\n newPosition = {\n left: menuContHalfWidth,\n top: position.top,\n };\n reveal = true;\n }\n\n // reposition if the menu head goes beyond the right side of the viewport\n if (right > screenWidth || screenWidth - right < menuContWidth) {\n newPosition = {\n left: screenWidth - menuContWidth,\n top: position.top,\n };\n\n if (flipOnEdges) {\n flipMenu = true;\n }\n reveal = true;\n }\n\n return {\n position: newPosition,\n flip: flipMenu,\n reveal,\n };\n },\n setupInitStyle: (position: string, dimension: number) => {\n const MENU_HEAD_SPACING = 15;\n\n let left = MENU_HEAD_SPACING,\n top = MENU_HEAD_SPACING;\n switch (position) {\n case 'top left':\n left = MENU_HEAD_SPACING;\n top = MENU_HEAD_SPACING;\n break;\n case 'top right':\n left = window.innerWidth - dimension - MENU_HEAD_SPACING;\n top = MENU_HEAD_SPACING;\n break;\n case 'bottom left':\n left = MENU_HEAD_SPACING;\n top = window.innerHeight - dimension - MENU_HEAD_SPACING;\n break;\n case 'bottom right':\n left = window.innerWidth - dimension - MENU_HEAD_SPACING;\n top = window.innerHeight - dimension - MENU_HEAD_SPACING;\n }\n\n return {\n left: `${left}px`,\n top: `${top}px`,\n width: `${dimension}px`,\n height: `${dimension}px`,\n };\n },\n detectDeviceType: () => {\n const screenWidth = screen.width;\n if (screenWidth <= 768) {\n return 'mobile';\n }\n return 'desktop';\n },\n};\n\nexport default utils;\n","<template>\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"50%\"\n height=\"50%\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n class=\"feather feather-menu\"\n >\n <line x1=\"3\" y1=\"12\" x2=\"21\" y2=\"12\" />\n <line x1=\"3\" y1=\"6\" x2=\"21\" y2=\"6\" />\n <line x1=\"3\" y1=\"18\" x2=\"21\" y2=\"18\" />\n </svg>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue';\n\nexport default defineComponent({\n name: 'MenuIcon',\n});\n</script>\n","<template>\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"50%\"\n height=\"50%\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n class=\"feather feather-menu\"\n >\n <line x1=\"3\" y1=\"12\" x2=\"21\" y2=\"12\" />\n <line x1=\"3\" y1=\"6\" x2=\"21\" y2=\"6\" />\n <line x1=\"3\" y1=\"18\" x2=\"21\" y2=\"18\" />\n </svg>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue';\n\nexport default defineComponent({\n name: 'MenuIcon',\n});\n</script>\n","<template>\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n class=\"feather feather-x\"\n >\n <line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\" />\n <line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\" />\n </svg>\n</template>\n\n<script lang=\"ts\">\nexport default {\n name: 'XIcon',\n};\n</script>\n","<template>\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n class=\"feather feather-x\"\n >\n <line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\" />\n <line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\" />\n </svg>\n</template>\n\n<script lang=\"ts\">\nexport default {\n name: 'XIcon',\n};\n</script>\n","<template>\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n class=\"feather feather-chevron-right\"\n >\n <polyline points=\"9 18 15 12 9 6\" />\n </svg>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue';\n\nexport default defineComponent({\n name: 'ChevronRightIcon',\n});\n</script>\n","<template>\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n class=\"feather feather-chevron-right\"\n >\n <polyline points=\"9 18 15 12 9 6\" />\n </svg>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue';\n\nexport default defineComponent({\n name: 'ChevronRightIcon',\n});\n</script>\n","<template>\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n class=\"feather feather-plus\"\n >\n <line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\" />\n <line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\" />\n </svg>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue';\n\nexport default defineComponent({\n name: 'PlusIcon',\n});\n</script>\n","<template>\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n class=\"feather feather-plus\"\n >\n <line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\" />\n <line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\" />\n </svg>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue';\n\nexport default defineComponent({\n name: 'PlusIcon',\n});\n</script>\n","<template>\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n class=\"feather feather-minus\"\n >\n <line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\" />\n </svg>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue';\n\nexport default defineComponent({\n name: 'PlusIcon',\n});\n</script>\n","<template>\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n class=\"feather feather-minus\"\n >\n <line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\" />\n </svg>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue';\n\nexport default defineComponent({\n name: 'PlusIcon',\n});\n</script>\n","export type Menu = {\n items: MenuItem[];\n};\n\nexport type MenuItem = {\n /**\n * display name of the Menu item\n *\n * @type {string}\n */\n name?: string;\n /**\n * data for the sub menu\n *\n * @type {Menu}\n */\n subMenu?: Menu;\n /**\n * unique id (this is auto generated)\n *\n * @type {string}\n */\n id?: string;\n /**\n * flag to show the sub menu\n *\n * @type {boolean}\n */\n showSubMenu?: boolean;\n /**\n * used to highlight the selection. used for styling purpose.\n *\n * @type {boolean}\n */\n selected?: boolean;\n /**\n * disables the menu item. all interactions are disabled\n *\n * @type {boolean}\n */\n disabled?: boolean;\n\n divider?: boolean;\n\n iconSlot?: string;\n\n /**\n * Optional click handler for the menu item\n *\n * @type {() => void}\n */\n click?: () => void;\n};\n\nexport type Position = {\n left: number;\n top: number;\n};\n\nexport type SetupOrientation = (\n head: HTMLElement,\n content: HTMLElement,\n headDimension: number,\n menuDimension: { height: number; width: number }\n) => {\n 'min-height': string;\n width: string;\n newOrientation: string;\n};\n\nexport type SetupMenuPosition = (\n element: HTMLElement,\n position: Position,\n flipOnEdges: boolean,\n menuContainer: HTMLElement\n) => {\n position: Position | null;\n flip: boolean;\n reveal: boolean;\n};\n\nexport type SetupInitStyle = (\n dockPosition: string,\n dimension: number\n) => {\n left: string;\n top: string;\n width: string;\n height: string;\n};\n\nexport type UtilsType = {\n setupMenuOrientation: SetupOrientation;\n setupMenuPosition: SetupMenuPosition;\n setupInitStyle: SetupInitStyle;\n detectDeviceType: () => string;\n};\nexport interface Theme {\n /**\n * targets the bg color of the menu head and selection highlight for sub menus.\n *\n * @type {string}\n */\n primary: string;\n\n /**\n * targets the text color\n *\n * @type {string}\n */\n textColor: string;\n\n /**\n * targets the background color of the menu\n *\n * @type {string}\n */\n menuBgColor: string;\n\n /**\n * targets the text color of the menu item when the item has a sub menu\n *\n * @type {string}\n */\n textSelectedColor: string;\n\n hoverBackground: string;\n}\n\nexport const ThemeDefault = {\n primary: '#4f46e5', // Modern indigo\n textColor: '#374151', // Professional gray\n menuBgColor: 'rgba(255, 255, 255, 0.95)', // Semi-transparent white\n textSelectedColor: '#1f2937', // Dark gray for selected text\n hoverBackground: 'rgba(79, 70, 229, 0.1)', // Light indigo for hover\n};\n\n// Bundle optimization types\nexport interface BundleOptimization {\n memory?: {\n used: number;\n total: number;\n limit: number;\n };\n isSlowNetwork?: boolean;\n isDevelopment?: boolean;\n isProduction?: boolean;\n}\n\n// Component instance type for Vue\nexport interface FloatMenuInstance {\n toggleMenu: () => void;\n closeMenu: () => void;\n openMenu: () => void;\n isMenuActive: () => boolean;\n}\n\n// Props interface for the component\nexport interface FloatMenuProps {\n dimension?: number;\n position?: string;\n fixed?: boolean;\n menuDimension?: { height: number; width: number };\n menuData?: MenuItem[];\n useCustomContent?: boolean;\n onSelected?: (val: string) => void;\n flipOnEdges?: boolean;\n theme?: Theme;\n preserveMenuPosition?: boolean;\n menuStyle?: string;\n}\n","import { ref, computed, onMounted, onUnmounted } from 'vue';\n\nexport interface TouchEvent {\n type: 'tap' | 'longpress' | 'swipe' | 'pinch';\n target: HTMLElement;\n startPosition: { x: number; y: number };\n endPosition?: { x: number; y: number };\n duration: number;\n force?: number;\n}\n\nexport interface SwipeDirection {\n direction: 'up' | 'down' | 'left' | 'right';\n velocity: number;\n distance: number;\n}\n\nexport const useTouchOptimizations = () => {\n const isTouchDevice = ref(false);\n const isLongPress = ref(false);\n const touchStartTime = ref(0);\n const touchStartPosition = ref({ x: 0, y: 0 });\n const touchEndPosition = ref({ x: 0, y: 0 });\n\n // Touch configuration\n const LONG_PRESS_DURATION = 500; // ms\n const TAP_THRESHOLD = 10; // pixels\n const SWIPE_THRESHOLD = 50; // pixels\n const SWIPE_VELOCITY_THRESHOLD = 0.3; // pixels per ms\n\n // Device detection\n const detectTouchDevice = () => {\n return (\n 'ontouchstart' in window ||\n navigator.maxTouchPoints > 0 ||\n /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)\n );\n };\n\n // Touch event handlers\n const handleTouchStart = (\n event: globalThis.TouchEvent,\n callback?: (touchEvent: TouchEvent) => void\n ) => {\n const touch = event.touches[0];\n touchStartTime.value = Date.now();\n touchStartPosition.value = { x: touch.clientX, y: touch.clientY };\n touchEndPosition.value = { x: touch.clientX, y: touch.clientY };\n\n // Start long press detection\n const longPressTimer = setTimeout(() => {\n if (isLongPress.value) return;\n\n isLongPress.value = true;\n const touchEvent: TouchEvent = {\n type: 'longpress',\n target: event.target as HTMLElement,\n startPosition: touchStartPosition.value,\n duration: Date.now() - touchStartTime.value,\n force: touch.force || 1,\n };\n\n callback?.(touchEvent);\n }, LONG_PRESS_DURATION);\n\n // Store timer for cleanup\n (\n event.target as HTMLElement & { _longPressTimer?: ReturnType<typeof setTimeout> }\n )._longPressTimer = longPressTimer;\n };\n\n const handleTouchMove = (event: globalThis.TouchEvent) => {\n const touch = event.touches[0];\n touchEndPosition.value = { x: touch.clientX, y: touch.clientY };\n\n // Calculate distance moved\n const distance = Math.sqrt(\n Math.pow(touchEndPosition.value.x - touchStartPosition.value.x, 2) +\n Math.pow(touchEndPosition.value.y - touchStartPosition.value.y, 2)\n );\n\n // Cancel long press if moved too much\n const target = event.target as HTMLElement & { _longPressTimer?: number };\n if (distance > TAP_THRESHOLD && target._longPressTimer) {\n clearTimeout(target._longPressTimer);\n isLongPress.value = false;\n }\n };\n\n const handleTouchEnd = (\n event: globalThis.TouchEvent,\n callback?: (touchEvent: TouchEvent) => void\n ) => {\n const touchEndTime = Date.now();\n const duration = touchEndTime - touchStartTime.value;\n\n // Clean up long press timer\n const target = event.target as HTMLElement & { _longPressTimer?: number };\n if (target._longPressTimer) {\n clearTimeout(target._longPressTimer);\n }\n\n // Skip if this was a long press\n if (isLongPress.value) {\n isLongPress.value = false;\n return;\n }\n\n const distance = Math.sqrt(\n Math.pow(touchEndPosition.value.x - touchStartPosition.value.x, 2) +\n Math.pow(touchEndPosition.value.y - touchStartPosition.value.y, 2)\n );\n\n const velocity = distance / duration;\n\n let touchEvent: TouchEvent;\n\n // Determine touch event type\n if (distance < TAP_THRESHOLD) {\n // Tap\n touchEvent = {\n type: 'tap',\n target: event.target as HTMLElement,\n startPosition: touchStartPosition.value,\n endPosition: touchEndPosition.value,\n duration,\n };\n } else if (distance > SWIPE_THRESHOLD && velocity > SWIPE_VELOCITY_THRESHOLD) {\n // Swipe\n touchEvent = {\n type: 'swipe',\n target: event.target as HTMLElement,\n startPosition: touchStartPosition.value,\n endPosition: touchEndPosition.value,\n duration,\n };\n } else {\n // Regular tap with some movement\n touchEvent = {\n type: 'tap',\n target: event.target as HTMLElement,\n startPosition: touchStartPosition.value,\n endPosition: touchEndPosition.value,\n duration,\n };\n }\n\n callback?.(touchEvent);\n };\n\n // Swipe direction detection\n const getSwipeDirection = (): SwipeDirection | null => {\n const deltaX = touchEndPosition.value.x - touchStartPosition.value.x;\n const deltaY = touchEndPosition.value.y - touchStartPosition.value.y;\n const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);\n\n if (distance < SWIPE_THRESHOLD) return null;\n\n const duration = Date.now() - touchStartTime.value;\n const velocity = distance / duration;\n\n if (Math.abs(deltaX) > Math.abs(deltaY)) {\n return {\n direction: deltaX > 0 ? 'right' : 'left',\n velocity,\n distance,\n };\n } else {\n return {\n direction: deltaY > 0 ? 'down' : 'up',\n velocity,\n distance,\n };\n }\n };\n\n // Haptic feedback (if supported)\n const triggerHapticFeedback = (intensity: 'light' | 'medium' | 'heavy' = 'light') => {\n if ('vibrate' in navigator) {\n const patterns = {\n light: [10],\n medium: [20],\n heavy: [30],\n };\n navigator.vibrate(patterns[intensity]);\n }\n };\n\n // Enhanced touch target sizing\n const ensureTouchTarget = (element: HTMLElement, minSize: number = 44) => {\n const rect = element.getBoundingClientRect();\n const currentSize = Math.min(rect.width, rect.height);\n\n if (currentSize < minSize) {\n const padding = (minSize - currentSize) / 2;\n element.style.padding = `${padding}px`;\n element.style.minWidth = `${minSize}px`;\n element.style.minHeight = `${minSize}px`;\n }\n };\n\n // Touch-friendly menu orientation\n const getOptimalMenuOrientation = (\n menuElement: HTMLElement,\n triggerElement: HTMLElement,\n viewport: { width: number; height: number }\n ) => {\n const triggerRect = triggerElement.getBoundingClientRect();\n const menuRect = menuElement.getBoundingClientRect();\n\n // On mobile, prefer bottom orientation for easier thumb access\n if (isTouchDevice.value && viewport.width < 768) {\n const spaceBelow = viewport.height - triggerRect.bottom;\n const spaceAbove = triggerRect.top;\n\n // If there's enough space below, use bottom orientation\n if (spaceBelow >= menuRect.height + 20) {\n return 'bottom';\n }\n // Otherwise use top if there's space\n else if (spaceAbove >= menuRect.height + 20) {\n return 'top';\n }\n // Fallback to bottom with scrolling\n else {\n return 'bottom-scroll';\n }\n }\n\n // Desktop logic remains the same\n return triggerRect.top > viewport.height / 2 ? 'top' : 'bottom';\n };\n\n // Enhanced accessibility for touch\n const enhanceAccessibility = (element: HTMLElement) => {\n // Add touch-action for better performance\n element.style.touchAction = 'manipulation';\n\n // Ensure proper focus handling\n if (!element.hasAttribute('tabindex')) {\n element.setAttribute('tabindex', '0');\n }\n\n // Add ARIA labels for screen readers\n if (!element.hasAttribute('aria-label') && !element.hasAttribute('aria-labelledby')) {\n element.setAttribute('aria-label', 'Interactive menu item');\n }\n\n // Add role if not present\n if (!element.hasAttribute('role')) {\n element.setAttribute('role', 'button');\n }\n };\n\n // Initialize touch optimizations\n onMounted(() => {\n isTouchDevice.value = detectTouchDevice();\n\n // Add CSS class for touch devices\n if (isTouchDevice.value) {\n document.body.classList.add('touch-device');\n }\n\n // Add touch-friendly CSS\n const style = document.createElement('style');\n style.textContent = `\n .touch-device .menu-list-item {\n min-height: 44px !important;\n padding: 12px 16px !important;\n touch-action: manipulation;\n }\n \n .touch-device .menu-head {\n min-width: 44px !important;\n min-height: 44px !important;\n touch-action: manipulation;\n }\n \n .touch-device .chev-icon {\n min-width: 44px !important;\n min-height: 44px !important;\n display: flex !important;\n align-items: center !important;\n justify-content: center !important;\n }\n \n @media (hover: none) and (pointer: coarse) {\n .menu-list-item:hover {\n background-color: transparent !important;\n }\n \n .menu-list-item:active {\n background-color: var(--hover-background) !important;\n transform: scale(0.98) !important;\n }\n }\n `;\n document.head.appendChild(style);\n });\n\n onUnmounted(() => {\n // Cleanup\n document.body.classList.remove('touch-device');\n });\n\n return {\n isTouchDevice: computed(() => isTouchDevice.value),\n isLongPress: computed(() => isLongPress.value),\n handleTouchStart,\n handleTouchMove,\n handleTouchEnd,\n getSwipeDirection,\n triggerHapticFeedback,\n ensureTouchTarget,\n getOptimalMenuOrientation,\n enhanceAccessibility,\n };\n};\n","import { computed, ref, onMounted } from 'vue';\n\n// Extend import.meta for Vite environment\ndeclare global {\n interface ImportMeta {\n env: {\n DEV?: boolean;\n PROD?: boolean;\n [key: string]: unknown;\n };\n }\n}\n\nexport interface BundleOptimization {\n lazyLoad: boolean;\n treeShaking: boolean;\n codesplitting: boolean;\n compression: boolean;\n}\n\n// Extend the Performance interface to include memory\ninterface PerformanceWithMemory extends Performance {\n memory?: {\n usedJSHeapSize: number;\n totalJSHeapSize: number;\n jsHeapSizeLimit: number;\n };\n}\n\n// Extend Navigator interface for connection\ninterface NavigatorWithConnection extends Navigator {\n connection?: {\n effectiveType?: string;\n };\n}\n\nexport const useBundleOptimizations = () => {\n const optimizations = ref<BundleOptimization>({\n lazyLoad: true,\n treeShaking: true,\n codesplitting: true,\n compression: true,\n });\n\n // Dynamic imports for better code splitting\n const loadComponent = async (componentName: string) => {\n try {\n switch (componentName) {\n case 'TouchOptimizations':\n return await import('./useTouchOptimizations');\n case 'Animations':\n return await import('./useAnimations');\n case 'Performance':\n return await import('./usePerformanceOptimizations');\n case 'ErrorHandling':\n return await import('./useErrorHandling');\n default:\n throw new Error(`Unknown component: ${componentName}`);\n }\n } catch (error) {\n console.warn(`Failed to load component ${componentName}:`, error);\n return null;\n }\n };\n\n // Lazy load composables based on feature detection\n const loadComposablesConditionally = async () => {\n const features = {\n touchDevice: 'ontouchstart' in window || navigator.maxTouchPoints > 0,\n reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,\n modernBrowser: 'IntersectionObserver' in window && 'ResizeObserver' in window,\n highPerformance: navigator.hardwareConcurrency && navigator.hardwareConcurrency >= 4,\n };\n\n const loadPromises = [];\n\n // Only load touch optimizations on touch devices\n if (features.touchDevice) {\n loadPromises.push(loadComponent('TouchOptimizations'));\n }\n\n // Only load animations if motion is preferred\n if (!features.reducedMotion) {\n loadPromises.push(loadComponent('Animations'));\n }\n\n // Load performance optimizations on high-performance devices\n if (features.highPerformance) {\n loadPromises.push(loadComponent('Performance'));\n }\n\n // Always load accessibility and error handling\n loadPromises.push(\n loadComponent('Accessibility'),\n loadComponent('ErrorHandling'),\n loadComponent('KeyboardNavigation')\n );\n\n try {\n await Promise.all(loadPromises);\n } catch (error) {\n console.warn('Some composables failed to load:', error);\n }\n };\n\n // Bundle size analysis\n const getBundleInfo = () => {\n return {\n // Estimate bundle sizes (in KB)\n core: 12, // Core menu functionality\n touchOptimizations: 3,\n accessibility: 2,\n animations: 2,\n performance: 1.5,\n errorHandling: 1,\n keyboardNavigation: 1.5,\n total: 23,\n };\n };\n\n // Performance monitoring\n const measurePerformance = (operationName: string, operation: () => void) => {\n if ('performance' in window && performance.mark) {\n const startMark = `${operationName}-start`;\n const endMark = `${operationName}-end`;\n const measureName = `${operationName}-duration`;\n\n performance.mark(startMark);\n operation();\n performance.mark(endMark);\n\n try {\n performance.measure(measureName, startMark, endMark);\n const measure = performance.getEntriesByName(measureName)[0];\n console.debug(`${operationName} took ${measure.duration.toFixed(2)}ms`);\n } catch (error) {\n console.warn('Performance measurement failed:', error);\n }\n } else {\n operation();\n }\n };\n\n // Tree shaking helper - mark functions as used\n const markAsUsed = (featureName: string) => {\n if (import.meta.env?.DEV) {\n console.debug(`Feature marked as used: ${featureName}`);\n }\n };\n\n // Memory usage monitoring\n const monitorMemoryUsage = () => {\n const perf = performance as PerformanceWithMemory;\n if ('memory' in perf && perf.memory) {\n return {\n used: perf.memory.usedJSHeapSize,\n total: perf.memory.totalJSHeapSize,\n limit: perf.memory.jsHeapSizeLimit,\n };\n }\n return null;\n };\n\n // Code splitting strategies\n const splitStrategy = computed(() => {\n const userAgent = navigator.userAgent.toLowerCase();\n const isMobile = /android|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent);\n const nav = navigator as NavigatorWithConnection;\n const isSlowConnection =\n ('connection' in navigator && nav.connection?.effectiveType === 'slow-2g') ||\n nav.connection?.effectiveType === '2g';\n\n if (isMobile || isSlowConnection) {\n return 'aggressive'; // More aggressive code splitting for mobile/slow connections\n } else {\n return 'standard'; // Standard code splitting for desktop\n }\n });\n\n // Compression utilities\n const enableCompression = () => {\n // Enable gzip/brotli compression hints\n if ('serviceWorker' in navigator) {\n // Service worker can handle compression\n return true;\n }\n return false;\n };\n\n // Resource preloading\n const preloadCriticalResources = () => {\n const criticalResources = [\n { href: '/vue-float-menu.css', as: 'style' },\n { href: '/vue-float-menu.js', as: 'script' },\n ];\n\n criticalResources.forEach((resource) => {\n const link = document.createElement('link');\n link.rel = 'preload';\n link.href = resource.href;\n link.as = resource.as;\n document.head.appendChild(link);\n });\n };\n\n onMounted(() => {\n // Initialize optimizations\n if (optimizations.value.lazyLoad) {\n loadComposablesConditionally();\n }\n\n if (optimizations.value.compression) {\n enableCompression();\n }\n\n // Preload critical resources in production\n if (import.meta.env && !import.meta.env.DEV) {\n preloadCriticalResources();\n }\n });\n\n return {\n optimizations: computed(() => optimizations.value),\n loadComponent,\n loadComposablesConditionally,\n getBundleInfo,\n measurePerformance,\n markAsUsed,\n monitorMemoryUsage,\n splitStrategy,\n enableCompression,\n preloadCriticalResources,\n };\n};\n","<template>\n <div\n ref=\"menuRef\"\n class=\"menu-wrapper\"\n role=\"menu\"\n aria-label=\"Context menu\"\n aria-orientation=\"vertical\"\n tabindex=\"0\"\n @keyup=\"handleKeyUpWithErrorHandling\"\n >\n <ul class=\"menu-list\" :style=\"themeStyles\">\n <li\n v-for=\"(\n { id, selected, name, subMenu, showSubMenu, disabled, divider, iconSlot }, index\n ) of menuItems\"\n :key=\"id\"\n :class=\"[\n { 'sub-menu': subMenu, selected, disabled, flip, divider },\n 'menu-list-item',\n menuStyle,\n ]\"\n :style=\"themeStyles\"\n role=\"menuitem\"\n :aria-setsize=\"menuItems.length\"\n :aria-posinset=\"index + 1\"\n :aria-haspopup=\"subMenu ? 'menu' : undefined\"\n :aria-expanded=\"subMenu ? !!showSubMenu : undefined\"\n :aria-disabled=\"!!disabled\"\n :tabindex=\"-1\"\n @mousedown=\"\n handleMenuItemClickWithErrorHandling(\n $event,\n id || '',\n name || '',\n !!subMenu,\n index,\n !!disabled,\n !!divider\n )\n \"\n @mouseenter=\"!prefersReducedMotion && handleMouseEnter($event)\"\n @mouseleave=\"!prefersReducedMotion && handleMouseLeave($event)\"\n >\n <template v-if=\"!divider\">\n <div :class=\"menuItemClass\" @click=\"$event.stopPropagation()\">\n <span v-if=\"iconSlot\" class=\"menu-item-icon\">\n <slot :name=\"iconSlot\" />\n </span>\n <span :class=\"['name', { disabled }]\">{{ name }}</span>\n <span\n v-if=\"subMenu\"\n :class=\"['chev-icon', { disabled, 'show-submenu': showSubMenu }, menuStyle]\"\n >\n <chev-right-icon v-if=\"!isAccordion\" />\n <plus-icon v-if=\"subMenu && !showSubMenu && isAccordion\" />\n <minus-icon v-if=\"subMenu && showSubMenu && isAccordion\" />\n </span>\n </div>\n <transition\n :name=\"!prefersReducedMotion ? 'submenu' : ''\"\n :duration=\"animationDuration\"\n @before-leave=\"handleBeforeTransitionLeave\"\n @after-enter=\"handleAfterTransitionEnter\"\n >\n <div\n v-if=\"!disabled && showSubMenu\"\n :class=\"[subMenuClass, { 'accordion-submenu': isAccordion }]\"\n :style=\"themeStyles\"\n :data-submenu-id=\"id\"\n role=\"menu\"\n aria-label=\"Submenu\"\n >\n <component\n :is=\"SubMenuComponent\"\n :data=\"subMenu?.items || []\"\n :on-selection=\"onSelection\"\n :theme=\"theme\"\n :on-close=\"handleSubmenuClose\"\n :flip=\"flip\"\n :menu-style=\"menuStyle\"\n >\n <template v-for=\"slot in Object.keys($slots)\" #[slot]=\"scope\">\n <slot :name=\"slot\" v-bind=\"scope\" />\n </template>\n </component>\n </div>\n </transition>\n </template>\n <template v-else>\n <span class=\"menu-item-divider\" />\n </template>\n </li>\n </ul>\n </div>\n</template>\n\n<script lang=\"ts\">\nimport {\n defineComponent,\n PropType,\n ref,\n resolveComponent,\n computed,\n onMounted,\n watch,\n nextTick,\n} from 'vue';\nimport ChevRightIcon from './icons/ChevRightIcon.vue';\nimport PlusIcon from './icons/PlusIcon.vue';\nimport MinusIcon from './icons/MinusIcon.vue';\nimport { MenuItem, Theme, ThemeDefault } from '../types';\nimport { useMenuState } from './composables/useMenuState';\nimport { useTouchOptimizations } from './composables/useTouchOptimizations';\nimport { useBundleOptimizations } from './composables/useBundleOptimizations';\n\nexport default defineComponent({\n name: 'FloatMenu',\n components: {\n ChevRightIcon,\n PlusIcon,\n MinusIcon,\n },\n props: {\n data: {\n type: Array as PropType<MenuItem[]>,\n default: () => [],\n },\n flip: {\n type: Boolean,\n default: false,\n },\n onSelection: {\n type: Function as PropType<(name: string, parent?: string) => void>,\n default: null,\n },\n onClose: {\n type: Function as PropType<(keyCodeUsed?: string) => void>,\n default: null,\n required: true,\n },\n theme: {\n type: Object as PropType<Theme>,\n required: false,\n default: ThemeDefault,\n },\n menuStyle: {\n type: String,\n default: 'slide-out',\n required: false,\n },\n isTouchDevice: {\n type: Boolean,\n default: false,\n },\n },\n setup(props) {\n // Bundle optimizations for performance\n const { measurePerformance, markAsUsed } = useBundleOptimizations();\n\n // Menu state management\n const {\n activeIndex,\n menuItems,\n toggleMenu,\n selectMenuItem,\n setActiveIndex,\n closeAllSubMenus,\n updateSelectedItem,\n } = useMenuState(props.data);\n\n // Reference to the menu element\n const menuRef = ref<HTMLDivElement>();\n\n // Resolve this component for usage in nested menus\n const SubMenuComponent = resolveComponent('FloatMenu');\n\n // Accessibility and animation preferences\n const prefersReducedMotion = ref(false);\n\n // Ensure the menu is focused when mounted for keyboard navigation\n onMounted(() => {\n const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');\n prefersReducedMotion.value = mediaQuery.matches;\n\n const handler = (e: MediaQueryListEvent) => {\n prefersReducedMotion.value = e.matches;\n };\n\n mediaQuery.addEventListener('change', handler);\n\n // Set focus on the menu when it mounts, with a slight delay to ensure it's ready\n nextTick(() => {\n if (menuRef.value) {\n menuRef.value.focus();\n\n // If there are menu items, select the first one for keyboard navigation\n if (menuItems.value.length > 0) {\n // Find first non-divider, non-disabled item\n const firstItemIndex = menuItems.value.findIndex(\n (item) => !item.divider && !item.disabled\n );\n if (firstItemIndex !== -1) {\n setActiveIndex(firstItemIndex);\n }\n }\n }\n });\n });\n\n // Animation duration based on user preferences\n const animationDuration = computed(() => {\n return prefersReducedMotion.value ? 0 : 300;\n });\n\n // Touch optimizations (conditionally import only if needed)\n const touchOptimizations = props.isTouchDevice\n ? (() => {\n const { triggerHapticFeedback } = useTouchOptimizations();\n return { triggerHapticFeedback };\n })()\n : null;\n\n // Enhanced menu item click handler with touch support\n const handleMenuItemClickWithErrorHandling = (\n event: MouseEvent | TouchEvent,\n id: string,\n name: string,\n subMenu: boolean,\n index: number,\n disabled: boolean,\n divider: boolean\n ) => {\n measurePerformance('menuItemClick', () => {\n markAsUsed('menuItemClick');\n\n try {\n event.stopPropagation();\n event.preventDefault();\n\n if (disabled || divider) {\n return;\n }\n\n // Provide haptic feedback on touch devices\n if (props.isTouchDevice && touchOptimizations) {\n touchOptimizations.triggerHapticFeedback('light');\n }\n\n setActiveIndex(index);\n\n // For mouse clicks, always select the item and don't toggle submenus\n if (!subMenu) {\n selectMenuItem(name, id, subMenu, false, props.onSelection);\n } else {\n // For submenu items, toggle the submenu\n toggleMenu(id, false);\n }\n\n // Direct style manipulation to remove any focus borders\n const target = event.currentTarget as HTMLElement;\n if (target) {\n // Forcibly remove any focus indicators\n target.style.outline = 'none';\n target.style.border = 'none';\n\n // Apply a setTimeout to ensure this persists after any CSS transitions\n setTimeout(() => {\n if (target && target.classList.contains('selected')) {\n target.style.outline = 'none';\n target.style.border = 'none';\n }\n }, 10);\n }\n\n // Announce selection for screen readers\n if (!subMenu && name) {\n // Create announcement for screen reader\n const announcement = document.createElement('div');\n announcement.setAttribute('aria-live', 'polite');\n announcement.className = 'sr-only';\n announcement.textContent = `Selected ${name}`;\n document.body.appendChild(announcement);\n setTimeout(() => document.body.removeChild(announcement), 1000);\n }\n } catch (error) {\n console.error('Menu item click failed:', error);\n }\n });\n };\n\n // Enhanced keyboard navigation handler\n const handleKeyUpWithErrorHandling = (event: KeyboardEvent) => {\n try {\n event.preventDefault();\n event.stopPropagation();\n\n const actvIndex = activeIndex.value;\n const item = menuItems.value[actvIndex > -1 ? actvIndex : 0];\n const keyCode = event.key;\n const len = props.data.length;\n\n switch (keyCode) {\n case 'ArrowDown':\n if (actvIndex < len - 1) {\n let nextIndex = actvIndex + 1;\n\n // Skip disabled items and dividers\n while (\n nextIndex < len &&\n (menuItems.value[nextIndex]?.divider || menuItems.value[nextIndex]?.disabled)\n ) {\n nextIndex++;\n }\n\n // If we reached the end, loop back to the start\n if (nextIndex >= len) {\n nextIndex = 0;\n // Skip disabled items and dividers at the beginning\n while (\n nextIndex < actvIndex &&\n (menuItems.value[nextIndex]?.divider || menuItems.value[nextIndex]?.disabled)\n ) {\n nextIndex++;\n }\n }\n\n setActiveIndex(nextIndex);\n } else if (actvIndex === len - 1) {\n // Find first non-disabled, non-divider item\n let nextIndex = 0;\n while (\n nextIndex < len &&\n (menuItems.value[nextIndex]?.divider || menuItems.value[nextIndex]?.disabled)\n ) {\n nextIndex++;\n }\n\n // If all items are disabled, keep current selection\n if (nextIndex >= len) {\n nextIndex = actvIndex;\n }\n\n setActiveIndex(nextIndex);\n }\n break;\n\n case 'ArrowUp':\n if (actvIndex > 0) {\n let prevIndex = actvIndex - 1;\n\n // Skip disabled items and dividers\n while (\n prevIndex >= 0 &&\n (menuItems.value[prevIndex]?.divider || menuItems.value[prevIndex]?.disabled)\n ) {\n prevIndex--;\n }\n\n // If we reached the beginning, loop to the end\n if (prevIndex < 0) {\n prevIndex = len - 1;\n // Skip disabled items and dividers at the end\n while (\n prevIndex > actvIndex &&\n (menuItems.value[prevIndex]?.divider || menuItems.value[prevIndex]?.disabled)\n ) {\n prevIndex--;\n }\n }\n\n setActiveIndex(prevIndex);\n } else {\n // Find last non-disabled, non-divider item\n let prevIndex = len - 1;\n while (\n prevIndex >= 0 &&\n (menuItems.value[prevIndex]?.divider || menuItems.value[prevIndex]?.disabled)\n ) {\n prevIndex--;\n }\n\n // If all items are disabled, keep current selection\n if (prevIndex < 0) {\n prevIndex = actvIndex;\n }\n\n setActiveIndex(prevIndex);\n }\n break;\n\n case 'ArrowLeft':\n // Skip disabled items\n if (item?.disabled) {\n break;\n }\n\n if (!props.flip) {\n props.onClose('ArrowLeft');\n } else if (item.subMenu) {\n toggleMenu(item.id, true);\n }\n break;\n\n case 'ArrowRight':\n // Skip disabled items\n if (item?.disabled) {\n break;\n }\n\n if (!props.flip && item?.subMenu) {\n toggleMenu(item.id || '', true);\n // Focus handling for keyboard navigation\n handleSubmenuOpen(item.id || '');\n } else if (props.flip) {\n props.onClose?.('ArrowRight');\n }\n break;\n\n case 'Enter':\n // Skip disabled items for Enter key actions\n if (item?.disabled) {\n break;\n }\n\n // Only select regular menu items with Enter, submenus should only use arrow keys\n selectMenuItem(\n item?.name,\n item?.id || '',\n Boolean(item?.subMenu),\n false,\n props.onSelection\n );\n\n // Announce selection for screen readers\n if (item?.name) {\n const announcement = document.createElement('div');\n announcement.setAttribute('aria-live', 'polite');\n announcement.className = 'sr-only';\n announcement.textContent = `Selected ${item.name}`;\n document.body.appendChild(announcement);\n setTimeout(() => document.body.removeChild(announcement), 1000);\n }\n break;\n\n case 'Escape':\n // Announce menu closing for screen readers\n const announcement = document.createElement('div');\n announcement.setAttribute('aria-live', 'polite');\n announcement.className = 'sr-only';\n announcement.textContent = 'Menu closed';\n document.body.appendChild(announcement);\n setTimeout(() => document.body.removeChild(announcement), 1000);\n\n props.onClose?.();\n break;\n }\n } catch (error) {\n console.error('Keyboard navigation failed:', error);\n }\n };\n\n // Mouse interaction handlers with smooth animations\n const handleMouseEnter = (event: MouseEvent) => {\n if (!prefersReducedMotion.value) {\n const target = event.currentTarget as HTMLElement;\n if (target) {\n target.style.transform = 'scale(1.02)';\n target.style.transition = 'all 150ms cubic-bezier(0.25, 0.46, 0.45, 0.94)';\n target.style.backgroundColor = 'var(--hover-background, rgba(0, 0, 0, 0.05))';\n }\n }\n };\n\n const handleMouseLeave = (event: MouseEvent) => {\n if (!prefersReducedMotion.value) {\n const target = event.currentTarget as HTMLElement;\n if (target) {\n target.style.transform = 'scale(1)';\n target.style.transition = 'all 150ms cubic-bezier(0.25, 0.46, 0.45, 0.94)';\n target.style.backgroundColor = '';\n }\n }\n };\n\n // Enhanced submenu focus handling for accessibility\n const handleSubmenuOpen = (submenuId: string) => {\n try {\n // Allow time for the submenu to render\n nextTick(() => {\n // Find the submenu container\n const submenu = document.querySelector(`[data-submenu-id=\"${submenuId}\"]`) as HTMLElement;\n if (submenu) {\n // Find the first non-disabled focusable item in the submenu\n const firstItem = submenu.querySelector(\n '[role=\"menuitem\"]:not([aria-disabled=\"true\"]):not(.divider)'\n ) as HTMLElement;\n if (firstItem) {\n firstItem.focus();\n\n // Announce submenu opening to screen readers\n const announcement = document.createElement('div');\n announcement.setAttribute('aria-live', 'polite');\n announcement.className = 'sr-only';\n announcement.textContent = 'Submenu opened';\n document.body.appendChild(announcement);\n setTimeout(() => document.body.removeChild(announcement), 1000);\n }\n }\n });\n } catch (error) {\n console.error('Submenu focus handling failed:', error);\n }\n };\n\n // Handle submenu close\n const handleSubmenuClose = () => {\n try {\n // Find all open submenus\n const openSubmenus = menuItems.value.filter((item) => item.showSubMenu);\n\n // If there are open submenus, find their DOM elements\n if (openSubmenus.length > 0) {\n nextTick(() => {\n // Start the animation by triggering the transition classes first\n // This will ensure the CSS transitions run properly\n closeAllSubMenus();\n\n // Then focus the menu after everything is closed\n setTimeout(() => {\n if (menuRef.value) {\n menuRef.value.focus();\n }\n }, 300); // Wait for the animation to finish\n });\n } else {\n // If no open submenus, just focus the menu\n if (menuRef.value) {\n menuRef.value.focus();\n }\n }\n } catch (error) {\n console.error('Submenu close failed:', error);\n }\n };\n\n // Enhanced submenu transitions\n const handleBeforeTransitionLeave = (_el: Element) => {\n try {\n // Announce submenu closing to screen readers if needed\n if (!prefersReducedMotion.value) {\n const announcement = document.createElement('div');\n announcement.setAttribute('aria-live', 'polite');\n announcement.className = 'sr-only';\n announcement.textContent = 'Submenu closing';\n document.body.appendChild(announcement);\n setTimeout(() => document.body.removeChild(announcement), 1000);\n }\n } catch (error) {\n console.error('Submenu transition handling failed:', error);\n }\n };\n\n const handleAfterTransitionEnter = (el: Element) => {\n try {\n // Focus the first non-disabled item in the submenu\n