Vue/tt icon select add multiple

This commit is contained in:
Luca Haid
2025-04-25 07:57:33 +00:00
parent b92f423761
commit 3e728f1806
3 changed files with 184 additions and 33 deletions

View File

@@ -20,6 +20,8 @@ class Helper {
$sql = " AND `$columnName` <= " . $filterValue['to'];
} else if (isset($filterValue['exact'])) {
$sql = " AND `$columnName` = " . "'{$filterValue['exact']}'";
} else if (!empty($filterValue)) {
$sql = " AND `$columnName` IN ('" . implode("','", $filterValue) . "')";
}
} else if ($filterValue === "0" || $filterValue === "1") {
$sql .= " AND `$columnName` = " . $filterValue;

View File

@@ -1,65 +1,214 @@
Vue.component('tt-icon-select', {
props: ['options', 'label', 'value'],
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
}
},
data() {
return {
selectedOption: this.options.find(option => option.value.toString() === this.value) || null,
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
},
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) {
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} selected`, showCaret: true };
}
} 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 };
}
}
},
mounted() {
if (! document.getElementById('tt-icon-select-style')) {
// Dynamically add CSS to disable default dropdown caret
// 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; }`;
document.body.appendChild(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
}
document.addEventListener('click', this.handleClick);
// Global click listener to close dropdown
document.addEventListener('click', this.handleGlobalClick);
this.observer = new ResizeObserver(this.calculateOffset.bind(this));
this.observer.observe(this.$refs.select);
this.calculateOffset();
// 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);
}
},
beforeDestroy() {
document.removeEventListener('click', this.handleClick);
this.observer.disconnect();
},
watch: {
value(val) {
this.selectedOption = this.options.find(option => option.value.toString() === val) || null;
// Clean up global listener and observer
document.removeEventListener('click', this.handleGlobalClick);
if (this.observer) {
this.observer.disconnect();
}
},
methods: {
/**
* Toggles the dropdown visibility.
*/
toggleDropdown() {
this.isOpen = !this.isOpen;
if (this.isOpen) {
// Recalculate offset when opening
this.$nextTick(this.calculateOffset);
}
},
/**
* Handles selection of an option from the dropdown.
*/
selectOption(option) {
this.selectedOption = option;
this.$emit('input', option ? option.value.toString() : '');
this.isOpen = false;
const selectedVal = option ? option.value : ''; // `null` represents the "Alle" option
if (this.multiple) {
let currentValues = [...this.internalValue]; // Work with a copy
const index = currentValues.findIndex(v => v?.toString() === selectedVal?.toString());
if (selectedVal === null) { // "Alle" clears selection in multi-mode
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
}
this.internalValue = currentValues; // Update via computed setter
} else {
// Single selection mode
this.internalValue = selectedVal; // Set the value directly
this.isOpen = false; // Close dropdown after selection
}
},
/**
* 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
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() {
const offset = (this.$refs.select.offsetWidth - 64.41) / 2;
this.$refs.select.querySelector('.dropdown-menu').style.left = `${offset}px`;
},
handleClick() {
this.isOpen = this.$refs.selectedIcon.contains(event.target) ? !this.isOpen : false;
// 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">
<div class="form-group tt-select" style="user-select: none; margin-bottom: 0; margin-top: 6px; position: relative;">
<div class="dropdown" :class="{'show': isOpen}">
<i v-if="selectedOption !== null" :class="selectedOption.icon" style="font-size: 18px; cursor: pointer" ref="selectedIcon"></i>
<span v-else style="cursor: pointer" ref="selectedIcon">Alle<i class="ml-1 fas fa-caret-down"></i></span>
<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>
</span>
<div style="display: grid; justify-items: center;" ref="select">
<div class="dropdown-menu" :class="{'show': isOpen}" style="min-width: unset !important;">
<a class="dropdown-item text-center" href="#" @click.prevent="selectOption(null)">Alle</a>
<a v-for="option in options" class="dropdown-item text-center" href="#"
<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>
<i :class="option.icon" style="font-size: 18px" :title="option.text || ''"></i>
</a>
</div>
</div>
</div>
</div>
`
});
});

View File

@@ -160,7 +160,7 @@ Vue.component('tt-table', {
</div>
<!-- @formatter:off -->
<tt-input v-if="column.filter === 'search' && !disableFiltering" v-model="filters[column.key]" sm/>
<tt-icon-select v-else-if="column.filter === 'iconSelect' && !disableFiltering" :options="column.filterOptions" v-model="filters[column.key]" sm/>
<tt-icon-select v-else-if="column.filter === 'iconSelect' && !disableFiltering" :options="column.filterOptions" v-model="filters[column.key]" sm :multiple="column.filterOptions.length > 2"/>
<tt-number-range v-else-if="column.filter === 'numberRange' && !disableFiltering" :returnText="!ssr" v-model="filters[column.key]" sm/>
<tt-select v-else-if="column.filter === 'select' && !disableFiltering" :options="[{text: 'Alle', value: undefined}, ...column.filterOptions]" v-model="filters[column.key]" sm/>
<tt-autocomplete v-else-if="column.filter === 'autocomplete' && !disableFiltering" :api-url="column.filterOptions" v-model="filters[column.key]" sm/>