feat: complete rewrite of custom select using Teleport for full floating and readonly interaction

This commit is contained in:
YunLong 2026-04-01 19:26:19 +08:00
parent efab565100
commit a388476648

View File

@ -240,25 +240,23 @@
<span class="help-icon" tabindex="0">?</span> <span class="help-icon" tabindex="0">?</span>
</RichTooltip> </RichTooltip>
</label> </label>
<div class="custom-select-wrapper" :class="{ 'select-disabled': isReadOnly }"> <div ref="selectWrapper" class="custom-select-wrapper" :class="{ 'select-disabled': isReadOnly }" @click="toggleDropdown">
<input <input
:id="`${modalId}-${field.name}`" :id="`${modalId}-${field.name}`"
:value="inputValue" :value="inputValue"
@input="onFilterInput"
@focus="showDropdown = true"
@blur="handleInputBlur"
class="form-input custom-select-input" class="form-input custom-select-input"
:readonly="isReadOnly" readonly
:class="{'input-readonly': isReadOnly}" :class="{'input-readonly': isReadOnly}"
:placeholder="$t('dynamic_form_field.type_to_filter')" :placeholder="$t('dynamic_form_field.type_to_filter')"
autocomplete="off" autocomplete="off"
/> />
<div v-if="showDropdown && !isReadOnly" class="custom-select-dropdown"> <Teleport to="body">
<div v-if="showDropdown && !isReadOnly" class="custom-select-dropdown" :style="dropdownStyle">
<div <div
v-if="!field.required" v-if="!field.required"
class="custom-select-option" class="custom-select-option"
:class="{ 'option-selected': formData[field.name] === null }" :class="{ 'option-selected': formData[field.name] === null }"
@mousedown="selectOption(null)" @mousedown.stop="selectOption(null)"
> >
None None
</div> </div>
@ -268,7 +266,7 @@
class="custom-select-option" class="custom-select-option"
:class="{ 'option-selected': formData[field.name] === (typeof option === 'string' ? option : option.value) }" :class="{ 'option-selected': formData[field.name] === (typeof option === 'string' ? option : option.value) }"
:title="typeof option === 'string' ? '' : (option.description || '')" :title="typeof option === 'string' ? '' : (option.description || '')"
@mousedown="selectOption(typeof option === 'string' ? option : option.value)" @mousedown.stop="selectOption(typeof option === 'string' ? option : option.value)"
> >
{{ {{
typeof option === 'string' typeof option === 'string'
@ -280,6 +278,7 @@
{{ $t('dynamic_form_field.no_options_found') }} {{ $t('dynamic_form_field.no_options_found') }}
</div> </div>
</div> </div>
</Teleport>
<div class="custom-select-arrow" :class="{ 'arrow-open': showDropdown }"> <div class="custom-select-arrow" :class="{ 'arrow-open': showDropdown }">
</div> </div>
@ -555,7 +554,7 @@
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref, reactive, watch, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import RichTooltip from './RichTooltip.vue' import RichTooltip from './RichTooltip.vue'
@ -610,6 +609,8 @@ const props = defineProps({
} }
}) })
const isReadOnly = computed(() => props.isReadOnly)
const emit = defineEmits([ const emit = defineEmits([
'update:form-data', 'update:form-data',
'open-child-modal', 'open-child-modal',
@ -632,6 +633,58 @@ const internalFormData = computed(() => props.formData)
// Custom select filtering state // Custom select filtering state
const showDropdown = ref(false) const showDropdown = ref(false)
const filterText = ref('') const filterText = ref('')
const selectWrapper = ref(null)
const dropdownRect = reactive({
top: 0,
left: 0,
width: 0
})
const updateDropdownPosition = () => {
if (selectWrapper.value) {
const rect = selectWrapper.value.getBoundingClientRect()
dropdownRect.top = rect.bottom
dropdownRect.left = rect.left
dropdownRect.width = rect.width
}
}
const dropdownStyle = computed(() => ({
position: 'fixed',
top: `${dropdownRect.top + 4}px`,
left: `${dropdownRect.left}px`,
width: `${dropdownRect.width}px`,
zIndex: 10000
}))
const toggleDropdown = () => {
if (props.isReadOnly) return
if (!showDropdown.value) {
updateDropdownPosition()
showDropdown.value = true
// Add escape listener
window.addEventListener('scroll', closeOnScroll, true)
window.addEventListener('mousedown', handleOutsideClick)
} else {
closeDropdown()
}
}
const closeDropdown = () => {
showDropdown.value = false
window.removeEventListener('scroll', closeOnScroll, true)
window.removeEventListener('mousedown', handleOutsideClick)
}
const closeOnScroll = () => {
if (showDropdown.value) closeDropdown()
}
const handleOutsideClick = (e) => {
if (selectWrapper.value && !selectWrapper.value.contains(e.target)) {
closeDropdown()
}
}
const isList = computed(() => { const isList = computed(() => {
return props.field.type && props.field.type.includes('list[') return props.field.type && props.field.type.includes('list[')
@ -728,8 +781,7 @@ const handleInputBlur = () => {
const selectOption = (value) => { const selectOption = (value) => {
props.formData[props.field.name] = value props.formData[props.field.name] = value
showDropdown.value = false closeDropdown()
filterText.value = ''
emit('handle-enum-change', props.field) emit('handle-enum-change', props.field)
} }
@ -1286,10 +1338,12 @@ input:checked + .switch-slider:before {
.custom-select-wrapper { .custom-select-wrapper {
position: relative; position: relative;
height: 38px; /* Fixed height to prevent absolute child from stretching parent */
} }
.custom-select-input { .custom-select-input {
cursor: pointer; cursor: pointer;
height: 100%;
} }
.custom-select-input:focus { .custom-select-input:focus {
@ -1299,7 +1353,202 @@ input:checked + .switch-slider:before {
.custom-select-arrow { .custom-select-arrow {
position: absolute; position: absolute;
right: 12px; right: 12px;
top: 18px; margin-left: 8px;
}
.delete-item-button:hover {
color: #ff6b6b;
}
.child-node-container {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.child-node-controls {
display: flex;
align-items: center;
gap: 5px;
}
.clear-child-button {
background: transparent;
border: none;
color: #999;
font-size: 18px;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s ease;
line-height: 1;
}
.clear-child-button:hover {
color: #ff6b6b;
}
.add-child-button,
.add-list-button,
.add-var-button {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.16);
background-color: rgba(255, 255, 255, 0.14);
color: rgba(242, 242, 242, 0.9);
cursor: pointer;
font-size: 13px;
transition: all 0.2s ease;
}
.add-child-button:hover,
.add-list-button:hover,
.add-var-button:hover {
background-color: rgba(255, 255, 255, 0.08);
}
.add-child-button .plus-icon,
.add-list-button .plus-icon,
.add-var-button .plus-icon {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
}
.add-child-button .plus-icon svg,
.add-list-button .plus-icon svg,
.add-var-button .plus-icon svg {
display: block;
}
.add-child-button:disabled {
opacity: 0.5;
cursor: not-allowed;
border-color: rgba(255, 255, 255, 0.1);
background-color: rgba(74, 74, 74, 0.8);
color: rgba(200, 200, 200, 0.7);
}
.child-node-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.child-node-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 14px;
background-color: rgba(90, 90, 90, 0.9);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 10px;
color: #f2f2f2;
font-size: 13px;
gap: 12px;
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.child-node-item:hover {
background-color: rgba(110, 110, 110, 0.95);
border-color: rgba(160, 196, 255, 0.7);
}
.child-node-name {
flex: 1;
}
.child-node-actions {
display: flex;
gap: 8px;
}
.child-node-item-wrapper {
display: flex;
flex-direction: column;
gap: 6px;
}
.child-node-summary {
padding: 6px 12px;
margin-left: 0;
background-color: rgba(60, 60, 60, 0.6);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
color: rgba(200, 200, 200, 0.85);
font-size: 12px;
line-height: 1.4;
word-wrap: break-word;
overflow-wrap: break-word;
white-space: pre-line;
}
.edit-child-button,
.delete-child-button {
padding: 6px 10px;
border-radius: 999px;
border: none;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: background-color 0.2s ease, color 0.2s ease;
}
.edit-child-button {
background-color: rgba(117, 117, 117, 0.95);
color: #f2f2f2;
}
.edit-child-button:hover {
background-color: rgba(136, 136, 136, 0.98);
}
.delete-child-button {
background-color: rgba(90, 90, 90, 0.9);
color: #f2f2f2;
}
.delete-child-button:hover {
background-color: rgba(110, 110, 110, 0.95);
}
.child-node-hint {
padding: 6px 8px;
color: rgba(189, 189, 189, 0.9);
font-size: 12px;
}
/* Custom select styles */
.custom-select-wrapper {
position: relative;
height: 38px; /* Fixed height to prevent absolute child from stretching parent */
}
.custom-select-input {
cursor: pointer;
height: 100%;
}
.custom-select-input:focus {
cursor: text;
}
.custom-select-arrow {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
color: rgba(242, 242, 242, 0.7); color: rgba(242, 242, 242, 0.7);
font-size: 12px; font-size: 12px;
@ -1311,38 +1560,37 @@ input:checked + .switch-slider:before {
transform: translateY(-50%) rotate(180deg); transform: translateY(-50%) rotate(180deg);
} }
.custom-select-dropdown { :global(.custom-select-dropdown) {
position: absolute; position: fixed;
z-index: 100; z-index: 10000;
max-height: 200px; max-height: 200px;
overflow-y: auto; overflow-y: auto;
background-color: rgba(25, 25, 25, 0.95); background-color: #1e1e1e;
border: 1px solid rgba(255, 255, 255, 0.14); border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.8);
margin-top: 2px; box-sizing: border-box;
width: 100%;
} }
.custom-select-option { :global(.custom-select-option) {
padding: 6px 6px; padding: 10px 14px;
cursor: pointer; cursor: pointer;
color: #f2f2f2; color: #f2f2f2;
font-size: 13px; font-size: 13px;
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
} }
.custom-select-option:hover { :global(.custom-select-option:hover) {
background-color: rgba(255, 255, 255, 0.1); background-color: rgba(255, 255, 255, 0.1);
} }
.custom-select-option.option-selected { :global(.custom-select-option.option-selected) {
background-color: rgba(160, 196, 255, 0.2); background-color: rgba(160, 196, 255, 0.2);
color: #a0c4ff; color: #a0c4ff;
font-weight: 500; font-weight: 500;
} }
.custom-select-no-results { :global(.custom-select-no-results) {
padding: 10px 12px; padding: 10px 12px;
color: rgba(189, 189, 189, 0.7); color: rgba(189, 189, 189, 0.7);
font-size: 12px; font-size: 12px;