Enhance Icon Selection and External URL Handling in Form Builder Components

- Updated FormBuilderFieldSettingsModal.vue and sideMenuNested.vue to replace text input for icon selection with an IconBrowser component, improving user experience for selecting Material Design icons.
- Enhanced CustomActionButton.vue to differentiate between internal and external URLs when navigating, ensuring proper handling of external links.
- Modified settings.local.json to include additional WebFetch commands for improved documentation access during development.
This commit is contained in:
Afiq 2025-08-08 11:33:58 +08:00
parent 9c6f1ef666
commit 2562ebbc74
5 changed files with 519 additions and 38 deletions

View File

@ -5,7 +5,10 @@
"Bash(yarn dev:*)",
"Bash(yarn lint:*)",
"Bash(yarn build:*)",
"Bash(npx eslint:*)"
"Bash(npx eslint:*)",
"WebFetch(domain:docs.iconify.design)",
"WebFetch(domain:iconify.design)",
"WebFetch(domain:api.iconify.design)"
],
"deny": []
}

View File

@ -140,12 +140,23 @@ const handleClick = async (event) => {
// Handle navigation based on target type
const openInNewTab = props.action.target === '_blank' || props.action.openInNewTab;
// Check if URL is external (has protocol or starts with //)
const isExternalUrl = (url) => {
return /^https?:\/\//.test(url) || /^\/\//.test(url) || !url.startsWith('/');
};
if (openInNewTab) {
window.open(finalUrl, '_blank', 'noopener,noreferrer');
} else {
// Use navigateTo for SPA navigation if available, otherwise use window.location
if (typeof navigateTo === 'function') {
await navigateTo(finalUrl);
if (isExternalUrl(finalUrl)) {
// For external URLs, use navigateTo with external: true option
await navigateTo(finalUrl, { external: true });
} else {
// For internal URLs, use regular navigateTo
await navigateTo(finalUrl);
}
} else {
window.location.href = finalUrl;
}

View File

@ -598,15 +598,18 @@
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="text"
label="Icon (Optional)"
name="icon"
v-model="configModel.icon"
help="Icon name (e.g., 'material-symbols:add')"
:classes="{ outer: 'field-wrapper' }"
placeholder="material-symbols:add"
/>
<div class="field-wrapper">
<label class="block text-sm font-medium text-gray-700 mb-1">Icon (Optional)</label>
<IconBrowser
v-model="configModel.icon"
size="compact"
:show-preview="false"
placeholder="Search Material Design icons..."
/>
<div class="text-xs text-gray-600 mt-1">
Icon displayed on the button
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
@ -1328,14 +1331,20 @@ if (name && email) {
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="text"
label="Icon (Optional)"
v-model="action.icon"
placeholder="e.g., heroicons:eye, material-symbols:edit"
help="Leave empty for automatic icon based on button style"
:classes="{ outer: 'field-wrapper' }"
/>
<div class="field-wrapper">
<label class="block text-sm font-medium text-gray-700 mb-1">
Icon (Optional)
</label>
<IconBrowser
v-model="action.icon"
placeholder="Search Material Design icons..."
size="compact"
:show-preview="false"
/>
<div class="text-xs text-gray-600 mt-1">
Leave empty for automatic icon based on button style
</div>
</div>
<FormKit
type="select"
@ -2576,14 +2585,15 @@ if (this.element.querySelector('.file-upload')) {
:classes="{ outer: 'mb-0' }"
/>
<FormKit
type="text"
label="Icon (optional)"
v-model="field.icon"
placeholder="material-symbols:link"
help="Icon name to display"
:classes="{ outer: 'mb-0' }"
/>
<div class="mb-0">
<label class="block text-sm font-medium text-gray-700 mb-1">Icon (optional)</label>
<IconBrowser
v-model="field.icon"
size="compact"
:show-preview="false"
placeholder="Search icons..."
/>
</div>
</div>
</div>
</div>
@ -3076,6 +3086,7 @@ if (this.element.querySelector('.file-upload')) {
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import ValidationRulesHelp from '~/components/ValidationRulesHelp.vue'
import IconBrowser from '~/components/IconBrowser.vue'
const props = defineProps({
modelValue: Boolean,

459
components/IconBrowser.vue Normal file
View File

@ -0,0 +1,459 @@
<template>
<div class="icon-browser">
<!-- Search Input -->
<div class="search-container">
<div class="relative">
<Icon name="heroicons:magnifying-glass" class="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
v-model="searchQuery"
type="text"
:placeholder="placeholder || 'Search icons...'"
:disabled="disabled"
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:border-purple-500 focus:ring focus:ring-purple-200 text-sm"
@input="debouncedSearch"
/>
<button
v-if="searchQuery"
@click="clearSearch"
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
title="Clear search"
>
<Icon name="heroicons:x-mark" class="w-4 h-4" />
</button>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" :class="gridClasses">
<div v-for="i in skeletonCount" :key="`skeleton-${i}`" class="icon-item skeleton">
<div class="icon-preview-skeleton"></div>
<div v-if="size !== 'compact'" class="icon-name-skeleton"></div>
</div>
</div>
<!-- Icon Grid -->
<div v-else-if="icons.length > 0" :class="gridClasses">
<div
v-for="icon in displayIcons"
:key="icon"
class="icon-item"
:class="{ 'selected': modelValue === icon }"
@click="selectIcon(icon)"
:title="icon"
>
<div class="icon-preview">
<Icon :name="icon" :class="iconSizeClass" />
<div v-if="modelValue === icon" class="selected-overlay">
<Icon name="heroicons:check" class="w-3 h-3 text-white" />
</div>
</div>
<div v-if="size !== 'compact'" class="icon-name">{{ getIconDisplayName(icon) }}</div>
</div>
</div>
<!-- Empty State -->
<div v-else-if="searchQuery && !loading" class="empty-state">
<Icon name="material-symbols:search-off" class="w-12 h-12 text-gray-400 mx-auto mb-3" />
<h3 class="text-lg font-medium text-gray-900 mb-2">No icons found</h3>
<p class="text-gray-600 mb-4">
No icons match your search "{{ searchQuery }}".
</p>
<p class="text-sm text-gray-500">
Try searching for: home, edit, delete, add, settings, arrow, check
</p>
</div>
<!-- Default State -->
<div v-else-if="!searchQuery && !loading" class="default-state">
<Icon name="material-symbols:palette" class="w-12 h-12 text-purple-400 mx-auto mb-3" />
<h3 class="text-lg font-medium text-gray-900 mb-2">Choose an Icon</h3>
<p class="text-gray-600 mb-4">
Search from over 14,000 Material Design icons.
</p>
<div class="popular-searches">
<p class="text-sm font-medium text-gray-700 mb-2">Popular searches:</p>
<div class="flex flex-wrap gap-2">
<button
v-for="term in popularSearches"
:key="term"
@click="searchQuery = term; debouncedSearch()"
class="px-3 py-1 text-sm bg-purple-100 text-purple-700 rounded-full hover:bg-purple-200 transition-colors"
>
{{ term }}
</button>
</div>
</div>
</div>
<!-- Selected Icon Preview -->
<div v-if="modelValue && showPreview" class="selected-preview">
<div class="preview-header">
<span class="text-sm font-medium text-gray-700">Selected Icon:</span>
<button
@click="clearSelection"
class="text-gray-400 hover:text-gray-600 transition-colors"
title="Clear selection"
>
<Icon name="heroicons:x-mark" class="w-4 h-4" />
</button>
</div>
<div class="preview-content">
<div class="preview-icon">
<Icon :name="modelValue" class="w-8 h-8 text-gray-700" />
</div>
<div class="preview-details">
<code class="text-sm text-purple-600 bg-purple-50 px-2 py-1 rounded">{{ modelValue }}</code>
</div>
</div>
</div>
<!-- Error State -->
<div v-if="hasError" class="error-state">
<div class="flex items-center text-red-600">
<Icon name="material-symbols:error" class="w-4 h-4 mr-2" />
<span class="text-sm">{{ errorMessage }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import { useDebounceFn } from '@vueuse/core';
const props = defineProps({
modelValue: {
type: String,
default: ''
},
placeholder: {
type: String,
default: 'Search icons...'
},
disabled: {
type: Boolean,
default: false
},
required: {
type: Boolean,
default: false
},
size: {
type: String,
default: 'normal', // 'compact' | 'normal' | 'large'
validator: (value) => ['compact', 'normal', 'large'].includes(value)
},
showPreview: {
type: Boolean,
default: true
}
});
const emit = defineEmits(['update:modelValue']);
// Reactive state
const searchQuery = ref('');
const icons = ref([]);
const loading = ref(false);
const error = ref('');
// Popular search terms for quick access
const popularSearches = ref([
'home', 'edit', 'delete', 'add', 'search', 'settings',
'arrow', 'check', 'close', 'menu', 'play', 'star'
]);
// Computed properties
const hasError = computed(() => {
if (props.required && !props.modelValue) return true;
return !!error.value;
});
const errorMessage = computed(() => {
if (props.required && !props.modelValue) {
return 'Icon selection is required';
}
return error.value;
});
const gridClasses = computed(() => {
const baseClass = 'icon-grid';
const sizeClasses = {
compact: 'icon-grid-compact',
normal: 'icon-grid-normal',
large: 'icon-grid-large'
};
return `${baseClass} ${sizeClasses[props.size]}`;
});
const iconSizeClass = computed(() => {
const sizeClasses = {
compact: 'w-5 h-5',
normal: 'w-6 h-6',
large: 'w-8 h-8'
};
return sizeClasses[props.size];
});
const skeletonCount = computed(() => {
const counts = {
compact: 12,
normal: 24,
large: 16
};
return counts[props.size];
});
const displayIcons = computed(() => {
const limits = {
compact: 12,
normal: 48,
large: 32
};
return icons.value.slice(0, limits[props.size]);
});
// Methods
const searchIcons = async (query) => {
if (!query || query.length < 2) {
icons.value = [];
return;
}
try {
loading.value = true;
error.value = '';
const response = await fetch(
`https://api.iconify.design/search?query=${encodeURIComponent(query)}&prefix=material-symbols&limit=48`
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
icons.value = data.icons || [];
} catch (err) {
console.error('Error fetching icons:', err);
error.value = 'Failed to load icons. Please try again.';
icons.value = [];
} finally {
loading.value = false;
}
};
// Debounced search function
const debouncedSearch = useDebounceFn(() => {
searchIcons(searchQuery.value);
}, 500);
const selectIcon = (iconName) => {
if (props.disabled) return;
emit('update:modelValue', iconName);
};
const clearSelection = () => {
emit('update:modelValue', '');
};
const clearSearch = () => {
searchQuery.value = '';
icons.value = [];
error.value = '';
};
const getIconDisplayName = (fullIconName) => {
// Remove the "material-symbols:" prefix for display
return fullIconName.replace('material-symbols:', '').replace(/-/g, ' ');
};
// Load default popular icons on component mount
const loadPopularIcons = async () => {
try {
loading.value = true;
const popularTerms = ['home', 'edit', 'delete', 'add', 'search', 'settings'];
const allIcons = [];
for (const term of popularTerms) {
const response = await fetch(
`https://api.iconify.design/search?query=${term}&prefix=material-symbols&limit=8`
);
if (response.ok) {
const data = await response.json();
allIcons.push(...(data.icons || []).slice(0, 4)); // Take first 4 from each category
}
}
// Remove duplicates and limit to 24 icons
icons.value = [...new Set(allIcons)].slice(0, 24);
} catch (err) {
console.error('Error loading popular icons:', err);
} finally {
loading.value = false;
}
};
// Load popular icons on mount
loadPopularIcons();
// Watch for external modelValue changes
watch(() => props.modelValue, (newValue) => {
// If external value changes, we might want to do something here
// For now, just ensure the error state is updated
error.value = '';
});
</script>
<style scoped>
.icon-browser {
@apply w-full;
}
.search-container {
@apply mb-4;
}
.icon-grid {
@apply border border-gray-200 rounded-md bg-gray-50 overflow-y-auto;
}
.icon-grid-compact {
@apply grid grid-cols-6 gap-1 max-h-32 p-1;
}
.icon-grid-normal {
@apply grid grid-cols-6 gap-3 max-h-80 p-2;
}
.icon-grid-large {
@apply grid grid-cols-4 gap-4 max-h-96 p-3;
}
.icon-item {
@apply flex flex-col items-center rounded-md cursor-pointer hover:bg-white hover:shadow-sm transition-all duration-200 border border-transparent;
}
.icon-grid-compact .icon-item {
@apply p-1;
}
.icon-grid-normal .icon-item {
@apply p-2;
}
.icon-grid-large .icon-item {
@apply p-3;
}
.icon-item.selected {
@apply bg-purple-50 border-purple-200 shadow-sm;
}
.icon-item.skeleton {
@apply cursor-default;
}
.icon-preview {
@apply relative flex items-center justify-center mb-1 rounded-md bg-gray-100;
}
.icon-grid-compact .icon-preview {
@apply w-8 h-8;
}
.icon-grid-normal .icon-preview {
@apply w-10 h-10;
}
.icon-grid-large .icon-preview {
@apply w-12 h-12;
}
.icon-item:hover .icon-preview {
@apply bg-white;
}
.icon-item.selected .icon-preview {
@apply bg-purple-100;
}
.selected-overlay {
@apply absolute -top-1 -right-1 w-5 h-5 bg-purple-600 rounded-full flex items-center justify-center;
}
.icon-name {
@apply text-xs text-gray-600 text-center leading-tight max-w-full overflow-hidden;
font-size: 10px;
line-height: 1.2;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.icon-item.selected .icon-name {
@apply text-purple-700 font-medium;
}
/* Skeleton Loading */
.icon-preview-skeleton {
@apply w-10 h-10 bg-gray-200 rounded-md animate-pulse;
}
.icon-name-skeleton {
@apply w-16 h-3 bg-gray-200 rounded animate-pulse mt-1;
}
/* States */
.empty-state,
.default-state {
@apply text-center py-12 px-4;
}
.popular-searches {
@apply mt-6;
}
.selected-preview {
@apply mt-4 p-3 bg-gray-50 border border-gray-200 rounded-md;
}
.preview-header {
@apply flex items-center justify-between mb-2;
}
.preview-content {
@apply flex items-center space-x-3;
}
.preview-icon {
@apply flex items-center justify-center w-12 h-12 bg-white rounded-md shadow-sm;
}
.preview-details {
@apply flex-1;
}
.error-state {
@apply mt-2;
}
/* Responsive adjustments */
@media (max-width: 640px) {
.icon-grid {
@apply grid-cols-4 gap-2;
}
.icon-name {
font-size: 9px;
}
}
@media (max-width: 480px) {
.icon-grid {
@apply grid-cols-3;
}
}
</style>

View File

@ -1,5 +1,6 @@
<script setup>
import DraggableSideMenuNested from "~/components/draggable/sideMenuNested.vue";
import IconBrowser from "~/components/IconBrowser.vue";
const props = defineProps({
menus: {
required: true,
@ -467,17 +468,13 @@ const removeChild = (type, data) => {
v-model="formMenu.path"
readonly
></FormKit>
<FormKit type="text" label="Icon" v-model="formMenu.icon"></FormKit>
<div class="mb-4 text-sm">
<p class="font-semibold mb-2">
Preview Icon (<a
href="https://icones.js.org/collection/all"
class="text-primary hover:underline"
target="_blank"
>https://icones.js.org/collection/all</a
>)
</p>
<Icon v-if="formMenu.icon" :name="formMenu.icon"></Icon>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">Icon</label>
<IconBrowser
v-model="formMenu.icon"
size="compact"
placeholder="Search Material Design icons..."
/>
</div>
</div>
<hr class="mb-4" />