improved tt-select and tt-icon-select

This commit is contained in:
Luca Haid
2025-10-08 14:55:14 +02:00
parent 803655bec0
commit 4713fc7dd0
6 changed files with 309 additions and 163 deletions

View File

@@ -48,6 +48,7 @@ $cssFiles = [
'plugins/vue/tt-components/css/tt-file-gallery.css', 'plugins/vue/tt-components/css/tt-file-gallery.css',
'plugins/vue/tt-components/css/tt-position-manager.css', 'plugins/vue/tt-components/css/tt-position-manager.css',
'plugins/vue/tt-components/css/tt-map.css', 'plugins/vue/tt-components/css/tt-map.css',
'plugins/vue/tt-components/css/tt-icon-select.css',
]; ];
// Output the combined and minified CSS // Output the combined and minified CSS

View File

@@ -0,0 +1,196 @@
/* --- tt-icon-select.css --- */
.tt-icon-select-modern {
position: relative;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.tt-icon-select-modern .tt-select-trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.5rem 0.75rem;
font-size: 1rem;
line-height: 1.5;
color: #495057;
background-color: #fff;
border: 1px solid #ced4da;
border-radius: 0.25rem;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
cursor: pointer;
text-align: left;
}
.tt-icon-select-modern .tt-select-trigger.sm {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
border-radius: 0.2rem;
}
.tt-icon-select-modern .tt-select-trigger:hover {
border-color: #80bdff;
}
.tt-icon-select-modern .tt-select-trigger.open {
border-color: #80bdff;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.tt-icon-select-modern .trigger-text {
display: flex;
align-items: center;
gap: 0.5rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-grow: 1;
}
.tt-icon-select-modern .trigger-text.placeholder {
color: #6c757d;
}
.tt-icon-select-modern .trigger-arrow {
margin-left: 8px;
transition: transform 0.2s ease;
}
.tt-icon-select-modern .tt-select-trigger.open .trigger-arrow {
transform: rotate(180deg);
}
.tt-icon-select-modern .tt-select-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
/* --- FIX: Allows dropdown to grow --- */
min-width: 100%;
width: max-content;
/* ------------------------------------ */
z-index: 1050;
background-color: #fff;
border-radius: 0.3rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
opacity: 0;
transform: translateY(-10px);
visibility: hidden;
transition: opacity 0.15s ease, transform 0.15s ease;
}
.tt-icon-select-modern .tt-select-dropdown.show {
opacity: 1;
transform: translateY(0);
visibility: visible;
}
.tt-icon-select-modern .dropdown-header {
display: none;
}
.tt-icon-select-modern .options-list {
list-style: none;
padding: 0.5rem 0;
margin: 0;
max-height: 250px;
overflow-y: auto;
}
/* Improved scrollbar styling */
.tt-icon-select-modern .options-list::-webkit-scrollbar {
width: 8px;
}
.tt-icon-select-modern .options-list::-webkit-scrollbar-track {
background: #f1f1f1;
}
.tt-icon-select-modern .options-list::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
.tt-icon-select-modern .options-list::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
.tt-icon-select-modern .option-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
cursor: pointer;
transition: background-color 0.15s ease;
user-select: none;
white-space: nowrap;
}
.tt-icon-select-modern .option-item:hover {
background-color: #f8f9fa;
}
.tt-icon-select-modern .option-item.selected {
background-color: #e2f0ff;
font-weight: 600;
color: #0056b3;
}
.tt-icon-select-modern .option-label {
display: flex;
align-items: center;
gap: 0.75rem;
}
.tt-icon-select-modern .option-label i {
width: 20px;
text-align: center;
}
.tt-icon-select-modern .option-checkmark {
color: #0056b3;
}
/* Mobile Compliance */
@media (max-width: 768px) {
.tt-icon-select-modern .tt-select-trigger,
.tt-icon-select-modern .option-item {
font-size: 1.1rem;
}
.tt-icon-select-modern .tt-select-dropdown.show {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
border-radius: 0;
display: flex;
flex-direction: column;
background-color: #f8f9fa;
transform: none;
}
.tt-icon-select-modern .dropdown-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
background-color: #fff;
border-bottom: 1px solid #dee2e6;
font-size: 1.2rem;
font-weight: 600;
}
.tt-icon-select-modern .close-btn {
font-size: 1.5rem;
cursor: pointer;
line-height: 1;
border: none;
background: none;
padding: 0 0.5rem;
}
.tt-icon-select-modern .options-list {
max-height: none;
flex-grow: 1;
}
}

View File

@@ -32,7 +32,6 @@
border-radius: 0.2rem; border-radius: 0.2rem;
} }
.tt-select-modern .tt-select-trigger:hover { .tt-select-modern .tt-select-trigger:hover {
border-color: #80bdff; border-color: #80bdff;
} }
@@ -109,6 +108,21 @@
overflow-y: auto; overflow-y: auto;
} }
/* Improved scrollbar styling */
.tt-select-modern .options-list::-webkit-scrollbar {
width: 8px;
}
.tt-select-modern .options-list::-webkit-scrollbar-track {
background: #f1f1f1;
}
.tt-select-modern .options-list::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
.tt-select-modern .options-list::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
.tt-select-modern .option-item { .tt-select-modern .option-item {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -129,7 +143,6 @@
display: none; display: none;
} }
.tt-select-modern .option-item:hover { .tt-select-modern .option-item:hover {
background-color: #f8f9fa; background-color: #f8f9fa;
} }

View File

@@ -1,213 +1,137 @@
Vue.component('tt-icon-select', { Vue.component('tt-icon-select', {
props: { props: {
options: { options: {type: Array, required: true},
type: Array, label: {type: String, default: null},
required: true value: {type: [String, Array, Number, null], default: null},
}, multiple: {type: Boolean, default: false},
label: String, // Kept as per request, though unused in original template sm: {type: Boolean, default: false} // Added sm prop
value: [String, Array, Number, null], // Allowed types
multiple: {
type: Boolean,
default: false
}
}, },
data() { data() {
return { return {
isOpen: false, isOpen: false,
observer: null,
}; };
}, },
computed: { computed: {
/**
* Internal representation of the value, ensuring it's an array in multiple mode.
* Uses computed property for cleaner prop sync than watchers.
*/
internalValue: { internalValue: {
get() { get() {
return this.multiple return this.multiple ? (Array.isArray(this.value) ? this.value : []) : this.value;
? (Array.isArray(this.value) ? this.value : []) // Use value if array, else empty array
: this.value; // Use value directly in single mode
}, },
set(newValue) { set(newValue) {
// Emit the change only if it's different to prevent potential loops
if (JSON.stringify(newValue) !== JSON.stringify(this.value)) { if (JSON.stringify(newValue) !== JSON.stringify(this.value)) {
this.$emit('input', newValue); 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() { triggerDisplay() {
if (this.multiple) { if (this.multiple) {
const count = this.internalValue.length; const count = this.internalValue.length;
if (count === 0) { if (count === 0) return { text: 'Auswählen...', isPlaceholder: true };
return { text: 'Alle', showCaret: true }; if (count === 1) {
} else if (count === 1) {
const opt = this.options.find(o => o.value?.toString() === this.internalValue[0]?.toString()); 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, text: opt.text, isPlaceholder: false } : { text: '1 ausgewählt', isPlaceholder: false };
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 { text: `${count} ausgewählt`, isPlaceholder: false };
} else { } else {
// Single selection mode: Find the corresponding option
const opt = this.options.find(o => o.value?.toString() === this.internalValue?.toString()); 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, text: opt.text, isPlaceholder: false } : { text: 'Auswählen...', isPlaceholder: true };
return opt ? { icon: opt.icon, title: opt.text } : { text: 'Alle', showCaret: true };
} }
} }
}, },
mounted() { mounted() {
// Add minimal CSS dynamically if not present document.addEventListener('click', this.onClickOutside);
if (!document.getElementById('tt-icon-select-style')) { document.addEventListener('tt-select-open', this.handleGlobalOpen);
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);
}
}, },
beforeDestroy() { beforeDestroy() {
// Clean up global listener and observer document.removeEventListener('click', this.onClickOutside);
document.removeEventListener('click', this.handleGlobalClick); document.removeEventListener('tt-select-open', this.handleGlobalOpen);
if (this.observer) {
this.observer.disconnect();
}
}, },
methods: { methods: {
/**
* Toggles the dropdown visibility.
*/
toggleDropdown() { 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; this.isOpen = !this.isOpen;
if (this.isOpen) { },
// Recalculate offset when opening onClickOutside(e) {
this.$nextTick(this.calculateOffset); 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) { selectOption(option) {
const selectedVal = option ? option.value : ''; // `null` represents the "Alle" option const selectedVal = option ? option.value : null;
if (this.multiple) { if (this.multiple) {
let currentValues = [...this.internalValue]; // Work with a copy let currentValues = [...this.internalValue];
const index = currentValues.findIndex(v => v?.toString() === selectedVal?.toString()); if (selectedVal === null) {
if (selectedVal === '') { // "Alle" clears selection in multi-mode
currentValues = []; 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 { } else {
// Single selection mode const index = currentValues.findIndex(v => v?.toString() === selectedVal?.toString());
this.internalValue = selectedVal; // Set the value directly if (index > -1) {
this.isOpen = false; // Close dropdown after selection currentValues.splice(index, 1);
} else {
currentValues.push(selectedVal);
}
}
this.internalValue = currentValues;
} else {
this.internalValue = selectedVal;
this.isOpen = false;
} }
}, },
/**
* Checks if a given option is currently selected.
* Used for applying the 'active' class.
*/
isSelected(option) { isSelected(option) {
const checkVal = option ? option.value : null; const checkVal = option ? option.value : null;
if (this.multiple) { 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()); return this.internalValue.some(v => v?.toString() === checkVal?.toString());
} else { } else {
// Simple comparison for single mode
return this.internalValue?.toString() === checkVal?.toString(); 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: ` template: `
<div class="form-group tt-select" style="user-select: none; margin-bottom: 0; margin-top: 6px; position: relative;"> <div class="tt-icon-select-modern">
<div class="dropdown" :class="{'show': isOpen}"> <button type="button" class="tt-select-trigger"
:class="{ 'open': isOpen, 'sm': sm }"
<span @click="toggleDropdown" ref="trigger" style="cursor: pointer; display: inline-block; min-width: 40px; text-align: center;"> @click.stop="toggleDropdown">
<i v-if="triggerDisplay.icon" <span class="trigger-text" :class="{'placeholder': triggerDisplay.isPlaceholder}">
:class="triggerDisplay.icon" <i v-if="triggerDisplay.icon" :class="triggerDisplay.icon"></i>
style="font-size: 18px;" <span>{{ triggerDisplay.text }}</span>
:title="triggerDisplay.title">
</i>
<span v-else>
{{ triggerDisplay.text }}
<i v-if="triggerDisplay.showCaret" class="ml-1 fas fa-caret-down"></i>
</span>
</span> </span>
<i class="fas fa-chevron-down trigger-arrow"></i>
</button>
<div style="display: grid; justify-items: center;" ref="select"> <div class="tt-select-dropdown" :class="{ show: isOpen }" @click.stop>
<div class="dropdown-menu" <div class="dropdown-header">
ref="dropdownMenu" <span>{{ label || 'Auswählen' }}</span>
:class="{'show': isOpen}" <button type="button" class="close-btn" @click="toggleDropdown">&times;</button>
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>
<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> </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>
</div> </div>
` `

View File

@@ -42,10 +42,12 @@ Vue.component('tt-select', {
mounted() { mounted() {
document.addEventListener('click', this.onClickOutside); document.addEventListener('click', this.onClickOutside);
document.addEventListener('tt-select-open', this.handleGlobalOpen);
}, },
beforeDestroy() { beforeDestroy() {
document.removeEventListener('click', this.onClickOutside); document.removeEventListener('click', this.onClickOutside);
document.removeEventListener('tt-select-open', this.handleGlobalOpen);
}, },
computed: { computed: {
@@ -98,7 +100,11 @@ Vue.component('tt-select', {
methods: { methods: {
toggleDropdown() { toggleDropdown() {
if (this.disabled) return; if (this.disabled) return;
if (!this.open) this.$emit('focus'); if (!this.open) {
this.$emit('focus');
// Dispatch event that this select is opening
document.dispatchEvent(new CustomEvent('tt-select-open', { detail: { id: this._uid } }));
}
this.open = !this.open; this.open = !this.open;
}, },
@@ -108,6 +114,12 @@ Vue.component('tt-select', {
} }
}, },
handleGlobalOpen(event) {
if (event.detail.id !== this._uid) {
this.open = false;
}
},
isSelected(opt) { isSelected(opt) {
return this.multiple return this.multiple
? Array.isArray(this.selected) && this.selected.includes(opt.value) ? Array.isArray(this.selected) && this.selected.includes(opt.value)

View File

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