|
| 1 | +<script setup lang="ts"> |
| 2 | +import { ref, watch, onMounted, shallowRef, type Component } from 'vue' |
| 3 | +import { Tag } from 'lucide-vue-next' |
| 4 | +
|
| 5 | +interface Props { |
| 6 | + /** Icon name to load (case-insensitive). Supports all Lucide icon names. */ |
| 7 | + name?: string | null |
| 8 | + /** CSS classes for styling the icon */ |
| 9 | + class?: string |
| 10 | +} |
| 11 | +
|
| 12 | +const props = withDefaults(defineProps<Props>(), { |
| 13 | + name: null, |
| 14 | + class: 'h-4 w-4' |
| 15 | +}) |
| 16 | +
|
| 17 | +// Use shallowRef for better performance with components |
| 18 | +const IconComponent = shallowRef<Component>(Tag) |
| 19 | +const isLoading = ref<boolean>(false) |
| 20 | +
|
| 21 | +// Global cache to store loaded icons across all instances |
| 22 | +const globalIconCache = new Map<string, Component>() |
| 23 | +
|
| 24 | +/** |
| 25 | + * Generate icon name variations to try |
| 26 | + */ |
| 27 | +function getIconVariations(iconName: string): string[] { |
| 28 | + const clean = iconName.trim() |
| 29 | +
|
| 30 | + // Generate different naming patterns |
| 31 | + const variations = [ |
| 32 | + clean, // Exact: "Database" |
| 33 | + clean.toLowerCase(), // Lowercase: "database" |
| 34 | + clean.charAt(0).toUpperCase() + clean.slice(1).toLowerCase(), // PascalCase: "Database" |
| 35 | + clean.charAt(0).toLowerCase() + clean.slice(1), // camelCase: "database" |
| 36 | + ] |
| 37 | +
|
| 38 | + // Add kebab-case version for compound words |
| 39 | + const kebabCase = clean.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '') |
| 40 | + if (kebabCase !== clean.toLowerCase()) { |
| 41 | + variations.push(kebabCase) // "shopping-cart" |
| 42 | + } |
| 43 | +
|
| 44 | + // Add space-to-dash version |
| 45 | + const spaceToDash = clean.replace(/\s+/g, '-').toLowerCase() |
| 46 | + if (spaceToDash !== clean.toLowerCase() && !variations.includes(spaceToDash)) { |
| 47 | + variations.push(spaceToDash) // "globe-lock" |
| 48 | + } |
| 49 | +
|
| 50 | + // Add versions without spaces |
| 51 | + const noSpaces = clean.replace(/\s+/g, '').toLowerCase() |
| 52 | + if (noSpaces !== clean.toLowerCase() && !variations.includes(noSpaces)) { |
| 53 | + variations.push(noSpaces) // "globelock" |
| 54 | + } |
| 55 | +
|
| 56 | + // Add Icon suffix versions |
| 57 | + variations.push(clean + 'Icon') // "DatabaseIcon" |
| 58 | + variations.push(`Lucide${clean}`) // "LucideDatabase" |
| 59 | +
|
| 60 | + // Remove duplicates and return |
| 61 | + return [...new Set(variations)] |
| 62 | +} |
| 63 | +
|
| 64 | +/** |
| 65 | + * Load icon with proper error handling and no blocking |
| 66 | + */ |
| 67 | +async function loadIcon(iconName: string): Promise<void> { |
| 68 | + if (!iconName || iconName.trim() === '') { |
| 69 | + IconComponent.value = Tag |
| 70 | + return |
| 71 | + } |
| 72 | +
|
| 73 | + const trimmedName = iconName.trim() |
| 74 | +
|
| 75 | + // Check global cache first |
| 76 | + if (globalIconCache.has(trimmedName)) { |
| 77 | + const cachedIcon = globalIconCache.get(trimmedName) |
| 78 | + if (cachedIcon) { |
| 79 | + IconComponent.value = cachedIcon |
| 80 | + } |
| 81 | + return |
| 82 | + } |
| 83 | +
|
| 84 | + // Prevent multiple simultaneous loads of the same icon |
| 85 | + if (isLoading.value) return |
| 86 | +
|
| 87 | + isLoading.value = true |
| 88 | +
|
| 89 | + try { |
| 90 | + // Import the entire lucide-vue-next module |
| 91 | + const lucideModule = await import('lucide-vue-next') as Record<string, any> |
| 92 | +
|
| 93 | + // Try different name variations |
| 94 | + const variations = getIconVariations(trimmedName) |
| 95 | + let foundIcon: Component | null = null |
| 96 | +
|
| 97 | + for (const variation of variations) { |
| 98 | + if (lucideModule[variation]) { |
| 99 | + foundIcon = lucideModule[variation] as Component |
| 100 | + break |
| 101 | + } |
| 102 | + } |
| 103 | +
|
| 104 | + if (foundIcon) { |
| 105 | + // Cache the successful result globally |
| 106 | + globalIconCache.set(trimmedName, foundIcon) |
| 107 | + IconComponent.value = foundIcon |
| 108 | + } else { |
| 109 | + // Icon not found, cache and use fallback |
| 110 | + globalIconCache.set(trimmedName, Tag) |
| 111 | + IconComponent.value = Tag |
| 112 | + } |
| 113 | + } catch (error) { |
| 114 | + // Import failed, cache and use fallback |
| 115 | + globalIconCache.set(trimmedName, Tag) |
| 116 | + IconComponent.value = Tag |
| 117 | + } finally { |
| 118 | + isLoading.value = false |
| 119 | + } |
| 120 | +} |
| 121 | +
|
| 122 | +// Watch for prop changes |
| 123 | +watch(() => props.name, (newName: string | null) => { |
| 124 | + if (newName) { |
| 125 | + loadIcon(newName) |
| 126 | + } else { |
| 127 | + IconComponent.value = Tag |
| 128 | + } |
| 129 | +}, { immediate: false }) |
| 130 | +
|
| 131 | +// Load initial icon |
| 132 | +onMounted(() => { |
| 133 | + if (props.name) { |
| 134 | + loadIcon(props.name) |
| 135 | + } |
| 136 | +}) |
| 137 | +</script> |
| 138 | + |
| 139 | +<template> |
| 140 | + <component |
| 141 | + :is="IconComponent" |
| 142 | + :class="props.class" |
| 143 | + :data-icon-name="props.name" |
| 144 | + :data-loading="isLoading" |
| 145 | + /> |
| 146 | +</template> |
0 commit comments