diff --git a/public/cssbundler.php b/public/cssbundler.php index c39a62fa1..e1d4ca1c5 100644 --- a/public/cssbundler.php +++ b/public/cssbundler.php @@ -48,6 +48,7 @@ $cssFiles = [ 'plugins/vue/tt-components/css/tt-file-gallery.css', 'plugins/vue/tt-components/css/tt-position-manager.css', 'plugins/vue/tt-components/css/tt-map.css', + 'plugins/vue/tt-components/css/tt-icon-select.css', ]; // Output the combined and minified CSS diff --git a/public/plugins/vue/tt-components/css/tt-icon-select.css b/public/plugins/vue/tt-components/css/tt-icon-select.css new file mode 100644 index 000000000..a4a11231a --- /dev/null +++ b/public/plugins/vue/tt-components/css/tt-icon-select.css @@ -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; + } +} \ No newline at end of file diff --git a/public/plugins/vue/tt-components/css/tt-select.css b/public/plugins/vue/tt-components/css/tt-select.css index a48a64045..882037d29 100644 --- a/public/plugins/vue/tt-components/css/tt-select.css +++ b/public/plugins/vue/tt-components/css/tt-select.css @@ -32,7 +32,6 @@ border-radius: 0.2rem; } - .tt-select-modern .tt-select-trigger:hover { border-color: #80bdff; } @@ -109,6 +108,21 @@ 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 { display: flex; align-items: center; @@ -129,7 +143,6 @@ display: none; } - .tt-select-modern .option-item:hover { background-color: #f8f9fa; } diff --git a/public/plugins/vue/tt-components/tt-icon-select.js b/public/plugins/vue/tt-components/tt-icon-select.js index f5ad16d0c..065fccbae 100644 --- a/public/plugins/vue/tt-components/tt-icon-select.js +++ b/public/plugins/vue/tt-components/tt-icon-select.js @@ -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: ` -
-