improved tt-select and tt-icon-select
This commit is contained in:
@@ -1,213 +1,137 @@
|
||||
Vue.component('tt-icon-select', {
|
||||
props: {
|
||||
options: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
label: String, // Kept as per request, though unused in original template
|
||||
value: [String, Array, Number, null], // Allowed types
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
options: {type: Array, required: true},
|
||||
label: {type: String, default: null},
|
||||
value: {type: [String, Array, Number, null], default: null},
|
||||
multiple: {type: Boolean, default: false},
|
||||
sm: {type: Boolean, default: false} // Added sm prop
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isOpen: false,
|
||||
observer: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
/**
|
||||
* Internal representation of the value, ensuring it's an array in multiple mode.
|
||||
* Uses computed property for cleaner prop sync than watchers.
|
||||
*/
|
||||
internalValue: {
|
||||
get() {
|
||||
return this.multiple
|
||||
? (Array.isArray(this.value) ? this.value : []) // Use value if array, else empty array
|
||||
: this.value; // Use value directly in single mode
|
||||
return this.multiple ? (Array.isArray(this.value) ? this.value : []) : this.value;
|
||||
},
|
||||
set(newValue) {
|
||||
// Emit the change only if it's different to prevent potential loops
|
||||
if (JSON.stringify(newValue) !== JSON.stringify(this.value)) {
|
||||
this.$emit('input', newValue);
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Determines what to display in the trigger area based on selection mode and value.
|
||||
* Returns an object { icon?: string, title?: string, text?: string, showCaret?: boolean }
|
||||
*/
|
||||
triggerDisplay() {
|
||||
if (this.multiple) {
|
||||
const count = this.internalValue.length;
|
||||
if (count === 0) {
|
||||
return { text: 'Alle', showCaret: true };
|
||||
} else if (count === 1) {
|
||||
if (count === 0) return { text: 'Auswählen...', isPlaceholder: true };
|
||||
if (count === 1) {
|
||||
const opt = this.options.find(o => o.value?.toString() === this.internalValue[0]?.toString());
|
||||
// Show icon if exactly one is selected and found
|
||||
return opt ? { icon: opt.icon, title: opt.text } : { text: '1 selected', showCaret: true };
|
||||
} else {
|
||||
// Show count for multiple items
|
||||
return { text: `${count}/${this.options.length}`, showCaret: true };
|
||||
return opt ? { icon: opt.icon, text: opt.text, isPlaceholder: false } : { text: '1 ausgewählt', isPlaceholder: false };
|
||||
}
|
||||
return { text: `${count} ausgewählt`, isPlaceholder: false };
|
||||
} else {
|
||||
// Single selection mode: Find the corresponding option
|
||||
const opt = this.options.find(o => o.value?.toString() === this.internalValue?.toString());
|
||||
// Return icon/title if found, otherwise the default "Alle" text
|
||||
return opt ? { icon: opt.icon, title: opt.text } : { text: 'Alle', showCaret: true };
|
||||
return opt ? { icon: opt.icon, text: opt.text, isPlaceholder: false } : { text: 'Auswählen...', isPlaceholder: true };
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// Add minimal CSS dynamically if not present
|
||||
if (!document.getElementById('tt-icon-select-style')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'tt-icon-select-style';
|
||||
style.innerHTML = `
|
||||
.tt-select .dropdown-toggle::after { display: none; }
|
||||
/* Minimal style for selected items in dropdown */
|
||||
.tt-select .dropdown-item:hover { background-color: #f8f9fa; } /* Subtle hover */
|
||||
.tt-select .dropdown-item.active { background-color: #e9ecef; font-weight: 500; } /* Subtle active */
|
||||
`;
|
||||
document.head.appendChild(style); // Append to head
|
||||
}
|
||||
|
||||
// Global click listener to close dropdown
|
||||
document.addEventListener('click', this.handleGlobalClick);
|
||||
|
||||
// Resize observer for positioning (references the container div 'select')
|
||||
if (this.$refs.select) {
|
||||
this.observer = new ResizeObserver(this.calculateOffset);
|
||||
this.observer.observe(this.$refs.select);
|
||||
// Ensure initial calculation happens after mount
|
||||
this.$nextTick(this.calculateOffset);
|
||||
}
|
||||
document.addEventListener('click', this.onClickOutside);
|
||||
document.addEventListener('tt-select-open', this.handleGlobalOpen);
|
||||
},
|
||||
beforeDestroy() {
|
||||
// Clean up global listener and observer
|
||||
document.removeEventListener('click', this.handleGlobalClick);
|
||||
if (this.observer) {
|
||||
this.observer.disconnect();
|
||||
}
|
||||
document.removeEventListener('click', this.onClickOutside);
|
||||
document.removeEventListener('tt-select-open', this.handleGlobalOpen);
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* Toggles the dropdown visibility.
|
||||
*/
|
||||
toggleDropdown() {
|
||||
if (!this.isOpen) {
|
||||
// Dispatch event that this select is opening
|
||||
document.dispatchEvent(new CustomEvent('tt-select-open', { detail: { id: this._uid } }));
|
||||
}
|
||||
this.isOpen = !this.isOpen;
|
||||
if (this.isOpen) {
|
||||
// Recalculate offset when opening
|
||||
this.$nextTick(this.calculateOffset);
|
||||
},
|
||||
onClickOutside(e) {
|
||||
if (this.$el && !this.$el.contains(e.target)) {
|
||||
this.isOpen = false;
|
||||
}
|
||||
},
|
||||
handleGlobalOpen(event) {
|
||||
if (event.detail.id !== this._uid) {
|
||||
this.isOpen = false;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Handles selection of an option from the dropdown.
|
||||
*/
|
||||
selectOption(option) {
|
||||
const selectedVal = option ? option.value : ''; // `null` represents the "Alle" option
|
||||
|
||||
const selectedVal = option ? option.value : null;
|
||||
if (this.multiple) {
|
||||
let currentValues = [...this.internalValue]; // Work with a copy
|
||||
const index = currentValues.findIndex(v => v?.toString() === selectedVal?.toString());
|
||||
|
||||
if (selectedVal === '') { // "Alle" clears selection in multi-mode
|
||||
let currentValues = [...this.internalValue];
|
||||
if (selectedVal === null) {
|
||||
currentValues = [];
|
||||
this.isOpen = false; // Close after clearing
|
||||
} else if (index > -1) { // Item exists, remove it
|
||||
currentValues.splice(index, 1);
|
||||
// Keep open to allow more selections/deselections
|
||||
} else { // Item doesn't exist, add it
|
||||
currentValues.push(selectedVal);
|
||||
// Keep open
|
||||
} else {
|
||||
const index = currentValues.findIndex(v => v?.toString() === selectedVal?.toString());
|
||||
if (index > -1) {
|
||||
currentValues.splice(index, 1);
|
||||
} else {
|
||||
currentValues.push(selectedVal);
|
||||
}
|
||||
}
|
||||
this.internalValue = currentValues; // Update via computed setter
|
||||
this.internalValue = currentValues;
|
||||
} else {
|
||||
// Single selection mode
|
||||
this.internalValue = selectedVal; // Set the value directly
|
||||
this.isOpen = false; // Close dropdown after selection
|
||||
this.internalValue = selectedVal;
|
||||
this.isOpen = false;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Checks if a given option is currently selected.
|
||||
* Used for applying the 'active' class.
|
||||
*/
|
||||
isSelected(option) {
|
||||
const checkVal = option ? option.value : null;
|
||||
if (this.multiple) {
|
||||
// Check if the value exists in the internal array
|
||||
if (checkVal === null) return this.internalValue.length === 0;
|
||||
return this.internalValue.some(v => v?.toString() === checkVal?.toString());
|
||||
} else {
|
||||
// Simple comparison for single mode
|
||||
return this.internalValue?.toString() === checkVal?.toString();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Calculates the dropdown menu's left offset to center it.
|
||||
* Based on original logic using the ref="select" container width.
|
||||
*/
|
||||
calculateOffset() {
|
||||
// Ensure refs are available
|
||||
if (this.$refs.select && this.$refs.dropdownMenu) {
|
||||
// Original calculation: (container width - approx menu item width) / 2
|
||||
// Let's refine slightly to use actual menu width if available
|
||||
const containerWidth = this.$refs.select.offsetWidth;
|
||||
const menuWidth = this.$refs.dropdownMenu.offsetWidth;
|
||||
// Adjust if needed, default uses original fixed width logic approx
|
||||
const offset = menuWidth > 0
|
||||
? (containerWidth - menuWidth) / 2
|
||||
: (containerWidth - 64.41) / 2; // Fallback to original approx offset
|
||||
this.$refs.dropdownMenu.style.left = `${offset}px`;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Closes the dropdown if a click occurs outside the component.
|
||||
*/
|
||||
handleGlobalClick(event) {
|
||||
if (this.$el && !this.$el.contains(event.target)) {
|
||||
this.isOpen = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="form-group tt-select" style="user-select: none; margin-bottom: 0; margin-top: 6px; position: relative;">
|
||||
<div class="dropdown" :class="{'show': isOpen}">
|
||||
|
||||
<span @click="toggleDropdown" ref="trigger" style="cursor: pointer; display: inline-block; min-width: 40px; text-align: center;">
|
||||
<i v-if="triggerDisplay.icon"
|
||||
:class="triggerDisplay.icon"
|
||||
style="font-size: 18px;"
|
||||
:title="triggerDisplay.title">
|
||||
</i>
|
||||
<span v-else>
|
||||
{{ triggerDisplay.text }}
|
||||
<i v-if="triggerDisplay.showCaret" class="ml-1 fas fa-caret-down"></i>
|
||||
</span>
|
||||
<div class="tt-icon-select-modern">
|
||||
<button type="button" class="tt-select-trigger"
|
||||
:class="{ 'open': isOpen, 'sm': sm }"
|
||||
@click.stop="toggleDropdown">
|
||||
<span class="trigger-text" :class="{'placeholder': triggerDisplay.isPlaceholder}">
|
||||
<i v-if="triggerDisplay.icon" :class="triggerDisplay.icon"></i>
|
||||
<span>{{ triggerDisplay.text }}</span>
|
||||
</span>
|
||||
<i class="fas fa-chevron-down trigger-arrow"></i>
|
||||
</button>
|
||||
|
||||
<div style="display: grid; justify-items: center;" ref="select">
|
||||
<div class="dropdown-menu"
|
||||
ref="dropdownMenu"
|
||||
:class="{'show': isOpen}"
|
||||
style="min-width: unset !important; position: absolute;"
|
||||
@click.stop> <a class="dropdown-item text-center" href="#"
|
||||
:class="{ active: isSelected(null) }"
|
||||
@click.prevent="selectOption(null)">
|
||||
{{ multiple ? 'Alle abwählen' : 'Alle' }}
|
||||
</a>
|
||||
|
||||
<a v-for="(option, index) in options"
|
||||
:key="option.value?.toString() + '-' + index"
|
||||
class="dropdown-item text-center" href="#"
|
||||
:class="{ active: isSelected(option) }"
|
||||
@click.prevent="selectOption(option)">
|
||||
<i :class="option.icon" style="font-size: 18px" :title="option.text || ''"></i>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
<div class="tt-select-dropdown" :class="{ show: isOpen }" @click.stop>
|
||||
<div class="dropdown-header">
|
||||
<span>{{ label || 'Auswählen' }}</span>
|
||||
<button type="button" class="close-btn" @click="toggleDropdown">×</button>
|
||||
</div>
|
||||
|
||||
<ul class="options-list">
|
||||
<li class="option-item"
|
||||
:class="{ 'selected': isSelected(null) }"
|
||||
@click="selectOption(null)">
|
||||
<div class="option-label">
|
||||
<i class="fas fa-ban"></i> <span>{{ multiple ? 'Alle abwählen' : 'Alle' }}</span>
|
||||
</div>
|
||||
<i v-if="isSelected(null)" class="fas fa-check option-checkmark"></i>
|
||||
</li>
|
||||
|
||||
<li v-for="opt in options" :key="opt.value"
|
||||
class="option-item"
|
||||
:class="{ 'selected': isSelected(opt) }"
|
||||
@click="selectOption(opt)">
|
||||
<div class="option-label">
|
||||
<i :class="opt.icon"></i>
|
||||
<span>{{ opt.text }}</span>
|
||||
</div>
|
||||
<i v-if="isSelected(opt)" class="fas fa-check option-checkmark"></i>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user