Enhance FormBuilderComponents with Search and Filter Functionality

- Updated FormBuilderComponents.vue to introduce an enhanced search bar with filtering options for components, improving user experience in locating specific items.
- Added a filter dropdown to allow users to filter components by category, with clear options for selection and removal of filters.
- Implemented recently used components section to provide quick access to frequently utilized items, enhancing workflow efficiency.
- Enhanced search functionality to highlight matching components and provide feedback when no results are found, ensuring better usability.
- Updated styles for improved visual consistency and responsiveness across different screen sizes.
- Modified settings.local.json to include ESLint command in the allowed commands list for better development practices.
This commit is contained in:
Afiq 2025-08-07 19:07:56 +08:00
parent 3abc215b42
commit 07539e2344
2 changed files with 678 additions and 53 deletions

View File

@ -4,8 +4,9 @@
"Bash(mkdir:*)", "Bash(mkdir:*)",
"Bash(yarn dev:*)", "Bash(yarn dev:*)",
"Bash(yarn lint:*)", "Bash(yarn lint:*)",
"Bash(yarn build:*)" "Bash(yarn build:*)",
"Bash(npx eslint:*)"
], ],
"deny": [] "deny": []
} }
} }

View File

@ -1,30 +1,112 @@
<template> <template>
<div class="form-components"> <div class="form-components">
<!-- Search Bar --> <!-- Enhanced Search Bar -->
<div class="search-container p-3 mb-2"> <div class="search-container p-3 mb-2">
<div class="relative"> <div class="relative">
<input <input
type="text" type="text"
v-model="searchQuery" v-model="searchQuery"
placeholder="Search..." placeholder="Search components..."
class="w-full px-3 py-2 pl-9 bg-white border border-gray-300 rounded text-gray-700 text-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" class="w-full px-3 py-2 pl-9 pr-10 bg-white border border-gray-300 rounded text-gray-700 text-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
@focus="showSearchOptions = true"
/> />
<Icon <Icon
name="material-symbols:search" name="material-symbols:search"
class="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" class="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400"
/> />
<button
@click="toggleFilterDropdown"
class="absolute right-3 top-1/2 transform -translate-y-1/2 p-1 hover:bg-gray-100 rounded transition-colors"
:class="{ 'text-blue-500': hasActiveFilters }"
>
<Icon name="material-symbols:filter-list" class="w-4 h-4" />
</button>
</div>
<!-- Filter Dropdown -->
<div
v-if="showFilterDropdown"
class="absolute z-50 mt-2 w-full bg-white border border-gray-200 rounded-lg shadow-lg"
>
<div class="p-3">
<h4 class="text-sm font-medium text-gray-700 mb-2">Filter by Category</h4>
<div class="space-y-2 max-h-48 overflow-y-auto">
<label
v-for="category in availableCategories"
:key="category"
class="flex items-center space-x-2 cursor-pointer hover:bg-gray-50 p-1 rounded"
>
<input
type="checkbox"
:value="category"
v-model="selectedCategories"
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<span class="text-sm text-gray-600">{{ category }}</span>
<span class="text-xs text-gray-400 ml-auto">
({{ getComponentsByCategory(category).length }})
</span>
</label>
</div>
<div class="flex justify-between mt-3 pt-2 border-t border-gray-100">
<button
@click="clearFilters"
class="text-xs text-gray-500 hover:text-gray-700"
>
Clear all
</button>
<button
@click="showFilterDropdown = false"
class="text-xs text-blue-600 hover:text-blue-700"
>
Done
</button>
</div>
</div>
</div> </div>
</div> </div>
<!-- Text Inputs Category --> <!-- Category Filter Chips -->
<div class="component-category mb-4"> <div v-if="selectedCategories.length > 0" class="px-3 mb-3">
<h3 class="text-gray-700 text-xs font-semibold px-3 mb-2 uppercase tracking-wider">Text Inputs</h3> <div class="flex flex-wrap gap-1">
<span
v-for="category in selectedCategories"
:key="category"
class="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full"
>
{{ category }}
<button
@click="removeCategory(category)"
class="ml-1 hover:bg-blue-200 rounded-full p-0.5"
>
<Icon name="material-symbols:close" class="w-3 h-3" />
</button>
</span>
<button
@click="clearFilters"
class="text-xs text-gray-500 hover:text-gray-700 ml-1"
>
Clear all
</button>
</div>
</div>
<!-- Recently Used Section -->
<div v-if="recentlyUsed.length > 0 && !searchQuery && selectedCategories.length === 0" class="mb-4">
<div class="flex items-center justify-between px-3 mb-2">
<h3 class="text-gray-700 text-xs font-semibold uppercase tracking-wider">Recently Used</h3>
<button
@click="clearRecentlyUsed"
class="text-xs text-gray-400 hover:text-gray-600"
>
Clear
</button>
</div>
<div class="grid grid-cols-2 gap-1 px-2"> <div class="grid grid-cols-2 gap-1 px-2">
<div <div
v-for="component in getComponentsByCategory('Basic Inputs')" v-for="component in recentlyUsed.slice(0, 4)"
:key="component.type" :key="'recent-' + component.type"
class="component-item rounded p-2 flex flex-col items-center justify-center cursor-grab hover:bg-gray-100 transition-colors border border-gray-200" class="component-item recent-item rounded p-2 flex flex-col items-center justify-center cursor-grab hover:bg-gray-100 transition-colors border border-gray-200 relative"
:class="{ 'hidden': !matchesSearch(component) }"
draggable="true" draggable="true"
@dragstart="onDragStart($event, component)" @dragstart="onDragStart($event, component)"
@dragend="onDragEnd($event)" @dragend="onDragEnd($event)"
@ -32,106 +114,292 @@
> >
<Icon :name="component.icon" class="mb-1 w-5 h-5 text-gray-600" /> <Icon :name="component.icon" class="mb-1 w-5 h-5 text-gray-600" />
<span class="text-xs text-gray-600 text-center">{{ component.name }}</span> <span class="text-xs text-gray-600 text-center">{{ component.name }}</span>
<div class="absolute top-0 right-0 w-2 h-2 bg-blue-500 rounded-full transform translate-x-1 -translate-y-1"></div>
</div>
</div>
</div>
<!-- No Results State -->
<div v-if="isSearching && filteredComponentsCount === 0" class="p-6 text-center text-gray-500">
<Icon name="material-symbols:search-off" class="w-12 h-12 mx-auto mb-3 text-gray-300" />
<h3 class="text-sm font-medium text-gray-600 mb-1">No components found</h3>
<p class="text-xs text-gray-400 mb-3">Try adjusting your search or filter criteria</p>
<button
@click="clearSearch"
class="text-xs text-blue-600 hover:text-blue-700 font-medium"
>
Clear search
</button>
</div>
<!-- Text Inputs Category -->
<div class="component-category mb-4" v-if="shouldShowCategory('Basic Inputs')">
<div
@click="toggleCategory('Basic Inputs')"
class="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-gray-50 rounded transition-colors"
>
<h3 class="text-gray-700 text-xs font-semibold uppercase tracking-wider">Text Inputs</h3>
<div class="flex items-center space-x-2">
<span class="text-xs text-gray-400">
({{ getVisibleComponentsInCategory('Basic Inputs').length }})
</span>
<Icon
:name="collapsedCategories['Basic Inputs'] ? 'material-symbols:expand-more' : 'material-symbols:expand-less'"
class="w-4 h-4 text-gray-400 transition-transform"
/>
</div>
</div>
<div
v-if="!collapsedCategories['Basic Inputs']"
class="grid grid-cols-2 gap-1 px-2 transition-all duration-200"
>
<div
v-for="component in getComponentsByCategory('Basic Inputs')"
:key="component.type"
class="component-item enhanced-card rounded p-2 flex flex-col items-center justify-center cursor-grab hover:bg-gray-100 transition-all duration-200 border border-gray-200"
:class="{ 'hidden': !matchesSearch(component), 'highlight-match': highlightComponent(component) }"
draggable="true"
@dragstart="onDragStart($event, component)"
@dragend="onDragEnd($event)"
@click="addComponent(component)"
@mouseenter="showTooltip($event, component)"
@mouseleave="hideTooltip"
>
<Icon :name="component.icon" class="mb-1 w-5 h-5 text-gray-600" />
<span
class="text-xs text-gray-600 text-center leading-tight component-name"
v-html="getHighlightedName(component)"
></span>
</div> </div>
</div> </div>
</div> </div>
<!-- Selection & Toggle Category --> <!-- Selection & Toggle Category -->
<div class="component-category mb-4"> <div class="component-category mb-4" v-if="shouldShowCategory('Selection Inputs')">
<h3 class="text-gray-700 text-xs font-semibold px-3 mb-2 uppercase tracking-wider">Selection & Toggle</h3> <div
<div class="grid grid-cols-2 gap-1 px-2"> @click="toggleCategory('Selection Inputs')"
class="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-gray-50 rounded transition-colors"
>
<h3 class="text-gray-700 text-xs font-semibold uppercase tracking-wider">Selection & Toggle</h3>
<div class="flex items-center space-x-2">
<span class="text-xs text-gray-400">
({{ getVisibleComponentsInCategory('Selection Inputs').length }})
</span>
<Icon
:name="collapsedCategories['Selection Inputs'] ? 'material-symbols:expand-more' : 'material-symbols:expand-less'"
class="w-4 h-4 text-gray-400 transition-transform"
/>
</div>
</div>
<div
v-if="!collapsedCategories['Selection Inputs']"
class="grid grid-cols-2 gap-1 px-2 transition-all duration-200"
>
<div <div
v-for="component in getComponentsByCategory('Selection Inputs')" v-for="component in getComponentsByCategory('Selection Inputs')"
:key="component.type" :key="component.type"
class="component-item rounded p-2 flex flex-col items-center justify-center cursor-grab hover:bg-gray-100 transition-colors border border-gray-200" class="component-item enhanced-card rounded p-2 flex flex-col items-center justify-center cursor-grab hover:bg-gray-100 transition-all duration-200 border border-gray-200"
:class="{ 'hidden': !matchesSearch(component) }" :class="{ 'hidden': !matchesSearch(component), 'highlight-match': highlightComponent(component) }"
draggable="true" draggable="true"
@dragstart="onDragStart($event, component)" @dragstart="onDragStart($event, component)"
@dragend="onDragEnd($event)" @dragend="onDragEnd($event)"
@click="addComponent(component)" @click="addComponent(component)"
@mouseenter="showTooltip($event, component)"
@mouseleave="hideTooltip"
> >
<Icon :name="component.icon" class="mb-1 w-5 h-5 text-gray-600" /> <Icon :name="component.icon" class="mb-1 w-5 h-5 text-gray-600" />
<span class="text-xs text-gray-600 text-center">{{ component.name }}</span> <span
class="text-xs text-gray-600 text-center leading-tight component-name"
v-html="getHighlightedName(component)"
></span>
</div> </div>
</div> </div>
</div> </div>
<!-- Date & Pickers Category --> <!-- Date & Pickers Category -->
<div class="component-category mb-4"> <div class="component-category mb-4" v-if="shouldShowCategory('Date and Time')">
<h3 class="text-gray-700 text-xs font-semibold px-3 mb-2 uppercase tracking-wider">Date & Pickers</h3> <div
<div class="grid grid-cols-2 gap-1 px-2"> @click="toggleCategory('Date and Time')"
class="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-gray-50 rounded transition-colors"
>
<h3 class="text-gray-700 text-xs font-semibold uppercase tracking-wider">Date & Pickers</h3>
<div class="flex items-center space-x-2">
<span class="text-xs text-gray-400">
({{ getVisibleComponentsInCategory('Date and Time').length }})
</span>
<Icon
:name="collapsedCategories['Date and Time'] ? 'material-symbols:expand-more' : 'material-symbols:expand-less'"
class="w-4 h-4 text-gray-400 transition-transform"
/>
</div>
</div>
<div
v-if="!collapsedCategories['Date and Time']"
class="grid grid-cols-2 gap-1 px-2 transition-all duration-200"
>
<div <div
v-for="component in getComponentsByCategory('Date and Time')" v-for="component in getComponentsByCategory('Date and Time')"
:key="component.type" :key="component.type"
class="component-item rounded p-2 flex flex-col items-center justify-center cursor-grab hover:bg-gray-100 transition-colors border border-gray-200" class="component-item enhanced-card rounded p-2 flex flex-col items-center justify-center cursor-grab hover:bg-gray-100 transition-all duration-200 border border-gray-200"
:class="{ 'hidden': !matchesSearch(component) }" :class="{ 'hidden': !matchesSearch(component), 'highlight-match': highlightComponent(component) }"
draggable="true" draggable="true"
@dragstart="onDragStart($event, component)" @dragstart="onDragStart($event, component)"
@dragend="onDragEnd($event)" @dragend="onDragEnd($event)"
@click="addComponent(component)" @click="addComponent(component)"
@mouseenter="showTooltip($event, component)"
@mouseleave="hideTooltip"
> >
<Icon :name="component.icon" class="mb-1 w-5 h-5 text-gray-600" /> <Icon :name="component.icon" class="mb-1 w-5 h-5 text-gray-600" />
<span class="text-xs text-gray-600 text-center">{{ component.name }}</span> <span
class="text-xs text-gray-600 text-center leading-tight component-name"
v-html="getHighlightedName(component)"
></span>
</div> </div>
</div> </div>
</div> </div>
<!-- Advanced & Upload Category --> <!-- Advanced & Upload Category -->
<div class="component-category mb-4"> <div class="component-category mb-4" v-if="shouldShowCategory('Advanced')">
<h3 class="text-gray-700 text-xs font-semibold px-3 mb-2 uppercase tracking-wider">Advanced & Upload</h3> <div
<div class="grid grid-cols-2 gap-1 px-2"> @click="toggleCategory('Advanced')"
class="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-gray-50 rounded transition-colors"
>
<h3 class="text-gray-700 text-xs font-semibold uppercase tracking-wider">Advanced & Upload</h3>
<div class="flex items-center space-x-2">
<span class="text-xs text-gray-400">
({{ getVisibleComponentsInCategory('Advanced').length }})
</span>
<Icon
:name="collapsedCategories['Advanced'] ? 'material-symbols:expand-more' : 'material-symbols:expand-less'"
class="w-4 h-4 text-gray-400 transition-transform"
/>
</div>
</div>
<div
v-if="!collapsedCategories['Advanced']"
class="grid grid-cols-2 gap-1 px-2 transition-all duration-200"
>
<div <div
v-for="component in getComponentsByCategory('Advanced')" v-for="component in getComponentsByCategory('Advanced')"
:key="component.type" :key="component.type"
class="component-item rounded p-2 flex flex-col items-center justify-center cursor-grab hover:bg-gray-100 transition-colors border border-gray-200" class="component-item enhanced-card rounded p-2 flex flex-col items-center justify-center cursor-grab hover:bg-gray-100 transition-all duration-200 border border-gray-200"
:class="{ 'hidden': !matchesSearch(component) }" :class="{ 'hidden': !matchesSearch(component), 'highlight-match': highlightComponent(component) }"
draggable="true" draggable="true"
@dragstart="onDragStart($event, component)" @dragstart="onDragStart($event, component)"
@dragend="onDragEnd($event)" @dragend="onDragEnd($event)"
@click="addComponent(component)" @click="addComponent(component)"
@mouseenter="showTooltip($event, component)"
@mouseleave="hideTooltip"
> >
<Icon :name="component.icon" class="mb-1 w-5 h-5 text-gray-600" /> <Icon :name="component.icon" class="mb-1 w-5 h-5 text-gray-600" />
<span class="text-xs text-gray-600 text-center">{{ component.name }}</span> <span
class="text-xs text-gray-600 text-center leading-tight component-name"
v-html="getHighlightedName(component)"
></span>
</div> </div>
</div> </div>
</div> </div>
<!-- Layout & Content Category --> <!-- Layout & Content Category -->
<div class="component-category mb-4"> <div class="component-category mb-4" v-if="shouldShowCategory('Layout')">
<h3 class="text-gray-700 text-xs font-semibold px-3 mb-2 uppercase tracking-wider">Layout & Content</h3> <div
<div class="grid grid-cols-2 gap-1 px-2"> @click="toggleCategory('Layout')"
class="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-gray-50 rounded transition-colors"
>
<h3 class="text-gray-700 text-xs font-semibold uppercase tracking-wider">Layout & Content</h3>
<div class="flex items-center space-x-2">
<span class="text-xs text-gray-400">
({{ getVisibleComponentsInCategory('Layout').length }})
</span>
<Icon
:name="collapsedCategories['Layout'] ? 'material-symbols:expand-more' : 'material-symbols:expand-less'"
class="w-4 h-4 text-gray-400 transition-transform"
/>
</div>
</div>
<div
v-if="!collapsedCategories['Layout']"
class="grid grid-cols-2 gap-1 px-2 transition-all duration-200"
>
<div <div
v-for="component in getComponentsByCategory('Layout')" v-for="component in getComponentsByCategory('Layout')"
:key="component.type" :key="component.type"
class="component-item rounded p-2 flex flex-col items-center justify-center cursor-grab hover:bg-gray-100 transition-colors border border-gray-200" class="component-item enhanced-card rounded p-2 flex flex-col items-center justify-center cursor-grab hover:bg-gray-100 transition-all duration-200 border border-gray-200"
:class="{ 'hidden': !matchesSearch(component) }" :class="{ 'hidden': !matchesSearch(component), 'highlight-match': highlightComponent(component) }"
draggable="true" draggable="true"
@dragstart="onDragStart($event, component)" @dragstart="onDragStart($event, component)"
@dragend="onDragEnd($event)" @dragend="onDragEnd($event)"
@click="addComponent(component)" @click="addComponent(component)"
@mouseenter="showTooltip($event, component)"
@mouseleave="hideTooltip"
> >
<Icon :name="component.icon" class="mb-1 w-5 h-5 text-gray-600" /> <Icon :name="component.icon" class="mb-1 w-5 h-5 text-gray-600" />
<span class="text-xs text-gray-600 text-center">{{ component.name }}</span> <span
class="text-xs text-gray-600 text-center leading-tight component-name"
v-html="getHighlightedName(component)"
></span>
</div> </div>
</div> </div>
</div> </div>
<!-- Custom HTML Category --> <!-- Custom HTML Category -->
<div class="component-category mb-4"> <div class="component-category mb-4" v-if="shouldShowCategory('Custom HTML')">
<h3 class="text-gray-700 text-xs font-semibold px-3 mb-2 uppercase tracking-wider">Custom HTML</h3> <div
<div class="grid grid-cols-2 gap-1 px-2"> @click="toggleCategory('Custom HTML')"
class="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-gray-50 rounded transition-colors"
>
<h3 class="text-gray-700 text-xs font-semibold uppercase tracking-wider">Custom HTML</h3>
<div class="flex items-center space-x-2">
<span class="text-xs text-gray-400">
({{ getVisibleComponentsInCategory('Custom HTML').length }})
</span>
<Icon
:name="collapsedCategories['Custom HTML'] ? 'material-symbols:expand-more' : 'material-symbols:expand-less'"
class="w-4 h-4 text-gray-400 transition-transform"
/>
</div>
</div>
<div
v-if="!collapsedCategories['Custom HTML']"
class="grid grid-cols-2 gap-1 px-2 transition-all duration-200"
>
<div <div
v-for="component in getComponentsByCategory('Custom HTML')" v-for="component in getComponentsByCategory('Custom HTML')"
:key="component.type" :key="component.type"
class="component-item rounded p-2 flex flex-col items-center justify-center cursor-grab hover:bg-gray-100 transition-colors border border-gray-200" class="component-item enhanced-card rounded p-2 flex flex-col items-center justify-center cursor-grab hover:bg-gray-100 transition-all duration-200 border border-gray-200"
:class="{ 'hidden': !matchesSearch(component) }" :class="{ 'hidden': !matchesSearch(component), 'highlight-match': highlightComponent(component) }"
draggable="true" draggable="true"
@dragstart="onDragStart($event, component)" @dragstart="onDragStart($event, component)"
@dragend="onDragEnd($event)" @dragend="onDragEnd($event)"
@click="addComponent(component)" @click="addComponent(component)"
@mouseenter="showTooltip($event, component)"
@mouseleave="hideTooltip"
> >
<Icon :name="component.icon" class="mb-1 w-5 h-5 text-gray-600" /> <Icon :name="component.icon" class="mb-1 w-5 h-5 text-gray-600" />
<span class="text-xs text-gray-600 text-center">{{ component.name }}</span> <span
class="text-xs text-gray-600 text-center leading-tight component-name"
v-html="getHighlightedName(component)"
></span>
</div>
</div>
</div>
<!-- Tooltip -->
<div
v-if="showTooltipData.show"
ref="tooltip"
class="tooltip-popup"
:style="tooltipStyle"
>
<div class="tooltip-content">
<div class="flex items-center space-x-2 mb-2">
<Icon :name="showTooltipData.component?.icon" class="w-4 h-4 text-gray-600" />
<span class="font-medium text-sm text-gray-800">{{ showTooltipData.component?.name }}</span>
</div>
<p class="text-xs text-gray-600">{{ showTooltipData.component?.description }}</p>
<div class="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
<span class="text-xs text-gray-400">{{ showTooltipData.component?.category }}</span>
<span class="text-xs text-blue-600">Click to add</span>
</div> </div>
</div> </div>
</div> </div>
@ -140,7 +408,31 @@
<script setup> <script setup>
const emit = defineEmits(['add-component']); const emit = defineEmits(['add-component']);
// Search and filtering state
const searchQuery = ref(''); const searchQuery = ref('');
const selectedCategories = ref([]);
const showFilterDropdown = ref(false);
const showSearchOptions = ref(false);
const recentlyUsed = ref([]);
// Close dropdown when clicking outside
const closeDropdown = (event) => {
if (!event.target.closest('.search-container')) {
showFilterDropdown.value = false;
showSearchOptions.value = false;
}
};
onMounted(() => {
document.addEventListener('click', closeDropdown);
loadRecentlyUsed();
loadCategoryStates();
});
onUnmounted(() => {
document.removeEventListener('click', closeDropdown);
});
// Define available form components // Define available form components
const availableComponents = [ const availableComponents = [
@ -1431,22 +1723,227 @@ fileInput.addEventListener('change', (e) => {
} }
]; ];
// Get all available categories
const availableCategories = computed(() => {
return [...new Set(availableComponents.map(component => component.category))];
});
// Get components by category for rendering in sections // Get components by category for rendering in sections
const getComponentsByCategory = (category) => { const getComponentsByCategory = (category) => {
return availableComponents.filter(component => component.category === category); return availableComponents.filter(component => component.category === category);
}; };
// Check if component matches search query // Get filtered components count for empty state
const filteredComponentsCount = computed(() => {
let components = availableComponents;
// Apply category filter
if (selectedCategories.value.length > 0) {
components = components.filter(component =>
selectedCategories.value.includes(component.category)
);
}
// Apply search filter
if (searchQuery.value) {
components = components.filter(component => matchesSearch(component));
}
return components.length;
});
// Check if we are in search mode
const isSearching = computed(() => {
return searchQuery.value.length > 0 || selectedCategories.value.length > 0;
});
// Check if there are active filters
const hasActiveFilters = computed(() => {
return selectedCategories.value.length > 0;
});
// Enhanced search matching with highlighting support
const matchesSearch = (component) => { const matchesSearch = (component) => {
if (!searchQuery.value) return true; // Always show if no search query
if (!searchQuery.value) {
// But apply category filter if active
if (selectedCategories.value.length > 0) {
return selectedCategories.value.includes(component.category);
}
return true;
}
const query = searchQuery.value.toLowerCase(); const query = searchQuery.value.toLowerCase();
return ( const matchesText = (
component.name.toLowerCase().includes(query) || component.name.toLowerCase().includes(query) ||
component.description.toLowerCase().includes(query) || component.description.toLowerCase().includes(query) ||
component.type.toLowerCase().includes(query) || component.type.toLowerCase().includes(query) ||
component.category.toLowerCase().includes(query) component.category.toLowerCase().includes(query)
); );
// Apply both search and category filter
if (selectedCategories.value.length > 0) {
return matchesText && selectedCategories.value.includes(component.category);
}
return matchesText;
};
// Filter functions
const toggleFilterDropdown = () => {
showFilterDropdown.value = !showFilterDropdown.value;
showSearchOptions.value = false;
};
const removeCategory = (category) => {
selectedCategories.value = selectedCategories.value.filter(c => c !== category);
};
const clearFilters = () => {
selectedCategories.value = [];
showFilterDropdown.value = false;
};
const clearSearch = () => {
searchQuery.value = '';
selectedCategories.value = [];
};
// Recently used functions
const addToRecentlyUsed = (component) => {
// Remove if already exists
recentlyUsed.value = recentlyUsed.value.filter(c => c.type !== component.type);
// Add to beginning
recentlyUsed.value.unshift(component);
// Keep only last 8
recentlyUsed.value = recentlyUsed.value.slice(0, 8);
// Save to localStorage
saveRecentlyUsed();
};
const loadRecentlyUsed = () => {
try {
const saved = localStorage.getItem('formBuilder_recentComponents');
if (saved) {
const parsed = JSON.parse(saved);
// Validate that components still exist
recentlyUsed.value = parsed.filter(component =>
availableComponents.some(c => c.type === component.type)
);
}
} catch (error) {
console.warn('Failed to load recently used components:', error);
recentlyUsed.value = [];
}
};
const saveRecentlyUsed = () => {
try {
localStorage.setItem('formBuilder_recentComponents', JSON.stringify(recentlyUsed.value));
} catch (error) {
console.warn('Failed to save recently used components:', error);
}
};
const clearRecentlyUsed = () => {
recentlyUsed.value = [];
localStorage.removeItem('formBuilder_recentComponents');
};
// Category collapse/expand functionality
const collapsedCategories = ref({});
const toggleCategory = (category) => {
collapsedCategories.value[category] = !collapsedCategories.value[category];
// Save state to localStorage
localStorage.setItem('formBuilder_collapsedCategories', JSON.stringify(collapsedCategories.value));
};
const loadCategoryStates = () => {
try {
const saved = localStorage.getItem('formBuilder_collapsedCategories');
if (saved) {
collapsedCategories.value = JSON.parse(saved);
}
} catch (error) {
console.warn('Failed to load category states:', error);
}
};
// Category visibility functions
const shouldShowCategory = (category) => {
const componentsInCategory = getComponentsByCategory(category);
const visibleComponents = componentsInCategory.filter(component => matchesSearch(component));
return visibleComponents.length > 0;
};
const getVisibleComponentsInCategory = (category) => {
const componentsInCategory = getComponentsByCategory(category);
return componentsInCategory.filter(component => matchesSearch(component));
};
// Component highlighting
const highlightComponent = (component) => {
if (!searchQuery.value) return false;
const query = searchQuery.value.toLowerCase();
return component.name.toLowerCase().includes(query);
};
// Tooltip functionality
const showTooltipData = ref({ show: false, component: null });
const tooltip = ref(null);
const tooltipStyle = ref({});
const showTooltip = (event, component) => {
showTooltipData.value = { show: true, component };
nextTick(() => {
const rect = event.target.getBoundingClientRect();
const tooltipEl = tooltip.value;
if (tooltipEl) {
const tooltipRect = tooltipEl.getBoundingClientRect();
// Position tooltip to the right of the component
let left = rect.right + 8;
let top = rect.top + (rect.height / 2) - (tooltipRect.height / 2);
// Adjust if tooltip would go off screen
if (left + tooltipRect.width > window.innerWidth) {
left = rect.left - tooltipRect.width - 8;
}
if (top < 0) {
top = 8;
} else if (top + tooltipRect.height > window.innerHeight) {
top = window.innerHeight - tooltipRect.height - 8;
}
tooltipStyle.value = {
position: 'fixed',
left: `${left}px`,
top: `${top}px`,
zIndex: 1000
};
}
});
};
const hideTooltip = () => {
showTooltipData.value = { show: false, component: null };
};
// Search highlighting functionality
const highlightSearchTerm = (text, searchTerm) => {
if (!searchTerm || !text) return text;
const regex = new RegExp(`(${searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return text.replace(regex, '<mark class="search-highlight">$1</mark>');
};
const getHighlightedName = (component) => {
if (!searchQuery.value) return component.name;
return highlightSearchTerm(component.name, searchQuery.value);
}; };
// Handle drag start event // Handle drag start event
@ -1511,6 +2008,7 @@ const onDragStart = (event, component) => {
// Add a component directly via click // Add a component directly via click
const addComponent = (component) => { const addComponent = (component) => {
addToRecentlyUsed(component);
emit('add-component', component); emit('add-component', component);
}; };
@ -1527,9 +2025,7 @@ const onDragEnd = (event) => {
<style scoped> <style scoped>
.form-components { .form-components {
@apply bg-white h-full; @apply bg-white h-full relative;
/* More compact layout */
font-size: 0.75rem; font-size: 0.75rem;
} }
@ -1538,13 +2034,33 @@ const onDragEnd = (event) => {
} }
.component-category h3 { .component-category h3 {
@apply text-xs font-semibold px-3 mb-2 uppercase tracking-wider text-gray-700; @apply text-xs font-semibold uppercase tracking-wider text-gray-700;
} }
/* Enhanced component items */
.component-item { .component-item {
@apply h-20; @apply h-20;
transition: all 0.15s ease-in-out; transition: all 0.15s ease-in-out;
min-height: 4.5rem; min-height: 4.5rem;
position: relative;
}
.component-item.enhanced-card {
@apply shadow-sm;
}
.component-item.enhanced-card:hover {
@apply shadow-md border-gray-300;
transform: translateY(-2px);
}
.component-item.highlight-match {
@apply ring-2 ring-yellow-200 bg-yellow-50 border-yellow-300;
}
.component-item.recent-item::after {
content: '';
@apply absolute top-0 right-0 w-2 h-2 bg-blue-500 rounded-full transform translate-x-1 -translate-y-1;
} }
.component-item .iconify { .component-item .iconify {
@ -1555,10 +2071,6 @@ const onDragEnd = (event) => {
@apply leading-tight; @apply leading-tight;
} }
.component-item:hover {
transform: translateY(-2px);
}
.component-item:active { .component-item:active {
transform: scale(0.97); transform: scale(0.97);
} }
@ -1570,8 +2082,120 @@ const onDragEnd = (event) => {
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.3); box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.3);
} }
/* Tooltip styles */
.tooltip-popup {
@apply bg-white border border-gray-200 rounded-lg shadow-lg;
max-width: 250px;
z-index: 1000;
}
.tooltip-content {
@apply p-3;
}
/* Filter dropdown styles */
.search-container {
position: relative;
}
/* Category header hover effects */
.component-category > div:first-child:hover {
@apply bg-gray-50;
}
/* Smooth transitions for category expansion */
.component-category .grid {
transition: all 0.2s ease-in-out;
}
/* Search highlight styles */
.highlight-text {
@apply bg-yellow-200;
}
.search-highlight,
:deep(.search-highlight) {
@apply bg-yellow-200 px-1 py-0.5 rounded text-gray-800 font-medium;
background-color: #fef3c7;
color: #92400e;
}
.component-name {
word-break: break-word;
}
.component-name :deep(mark) {
@apply bg-yellow-200 px-1 py-0.5 rounded text-gray-800 font-medium;
background-color: #fef3c7;
color: #92400e;
}
/* Enhanced search input */
.search-container input:focus {
@apply ring-2 ring-blue-500 border-blue-500;
}
/* Filter chip styles */
.filter-chip {
@apply inline-flex items-center px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full;
}
.filter-chip button {
@apply ml-1 hover:bg-blue-200 rounded-full p-0.5 transition-colors;
}
/* Recently used indicator */
.recent-indicator {
@apply absolute top-0 right-0 w-2 h-2 bg-blue-500 rounded-full transform translate-x-1 -translate-y-1;
}
/* Empty state styles */
.empty-state {
@apply text-center text-gray-500 py-8;
}
.empty-state h3 {
@apply text-sm font-medium text-gray-600 mb-1;
}
.empty-state p {
@apply text-xs text-gray-400 mb-3;
}
.drag-preview { .drag-preview {
pointer-events: none; pointer-events: none;
z-index: 9999; z-index: 9999;
} }
/* Responsive adjustments */
@media (max-width: 640px) {
.component-item {
@apply h-16;
min-height: 4rem;
}
.component-item .iconify {
@apply w-4 h-4;
}
.component-item span {
@apply text-xs;
}
}
/* Animation for category expand/collapse */
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.component-category .grid {
animation: slideDown 0.2s ease-out;
}
</style> </style>