Files
App.k12.Parents/uni_modules/uni-combox-x/components/uni-combox/uni-combox.uvue
T
2026-10-29 10:08:39 +08:00

661 lines
14 KiB
Plaintext

<template>
<view :id="anchorId" class="uni-combox">
<view
class="uni-combox__field"
@click="onFieldClick"
<!-- #ifdef WEB -->
tabindex="0"
@keydown="onInputKeydown"
<!-- #endif -->
>
<view v-if="inputMode" class="uni-combox__input-slot">
<slot
name="input"
:value="currentValue"
:input="onInput"
:focus="onInputFocus"
></slot>
</view>
<text
v-else
class="uni-combox__result"
:class="[resultTextClass, currentValue == '' ? 'uni-combox__result--placeholder' : '']"
max-lines="1"
>{{ displayText }}</text>
<view class="uni-combox__arrow-wrap" @click.stop="toggleOptionsByArrow">
<view class="uni-combox__arrow" :class="[arrowClass, opened ? 'uni-combox__arrow--open' : '']"></view>
</view>
</view>
<!-- #ifdef WEB || (APP-ANDROID && !VUE3-VAPOR) -->
<teleport v-if="opened && popupReady" to="body">
<!-- #endif -->
<!-- #ifndef WEB || (APP-ANDROID && !VUE3-VAPOR) -->
<teleport v-if="opened && popupReady" to="page">
<!-- #endif -->
<view class="uni-combox__mask" @click="closeOptions">
<view
class="uni-combox__options"
:class="optionsViewClass"
:style="optionsStyle"
@click.stop="noop"
>
<scroll-view
direction="vertical"
class="uni-combox__options-scroll"
:scroll-into-view="scrollIntoViewId"
>
<view class="uni-combox__options-content">
<view
v-if="visibleOptions.length == 0"
class="uni-combox__option-row"
>
<text
class="uni-combox__option uni-combox__option--empty"
:class="optionTextClass"
max-lines="1"
>无匹配项</text>
</view>
<view
v-for="(item, index) in visibleOptions"
:id="getOptionId(index)"
:key="item + '_' + index.toString()"
class="uni-combox__option-row"
:class="index == highlightedIndex ? 'uni-combox__option-row--highlight' : ''"
@click.stop="selectOption(index)"
>
<text
class="uni-combox__option"
:class="[optionTextClass, index == highlightedIndex ? optionTextHighlightClass : '']"
max-lines="1"
>{{ item }}</text>
</view>
</view>
</scroll-view>
</view>
</view>
</teleport>
</view>
</template>
<script setup lang="ts">
/**
* Combox 组合框
* @description 默认是单选选择框,通过 input 具名插槽切换为可输入、可选择的组合框。
* @uniPlatform {
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* }
* }
* @property {Array<String>} options 候选字符串数组
* @property {String} modelValue 当前值,支持 v-model
* @property {String} placeholder 占位文字
* @property {String} arrowClass 右侧箭头自定义样式类
* @property {String} optionsViewClass 候选浮层外层自定义样式类
* @property {String} optionTextClass 候选项文字自定义样式类
* @property {String} optionTextHighlightClass 候选项高亮自定义样式类
* @property {String} resultTextClass 单选模式结果文字自定义样式类
* @event {Function} update:modelValue 值更新时触发
* @event {Function} selectchange 从候选项选中时触发
*/
defineOptions({
externalClasses: [
'arrow-class',
'options-view-class',
'option-text-class',
'option-text-highlight-class',
'result-text-class'
]
})
type Props = {
options? : string[],
modelValue? : string,
placeholder? : string,
arrowClass? : string.ClassString,
optionsViewClass? : string.ClassString,
optionTextClass? : string.ClassString,
optionTextHighlightClass? : string.ClassString,
resultTextClass? : string.ClassString
}
const props = withDefaults(defineProps<Props>(), {
options: () : string[] => [] as string[],
modelValue: '',
placeholder: '',
arrowClass: '',
optionsViewClass: '',
optionTextClass: '',
optionTextHighlightClass: '',
resultTextClass: ''
})
const emit = defineEmits<{
'update:modelValue': [value : string],
selectchange: [value : string]
}>()
defineSlots<{
input(props : {
value : string,
input : (event : UniInputEvent) => void,
focus : (event : UniInputFocusEvent) => void
}) : any
}>()
const slots = useSlots()
const anchorId = `uni_combox_x_${Math.ceil(Math.random() * 10e8).toString(36)}`
const optionIdPrefix = `uni_combox_x_option_${Math.ceil(Math.random() * 10e8).toString(36)}_`
const optionHeight = 36
const defaultMaxOptionsHeight = 200
const popupVerticalPadding = 8
const viewportGap = 8
const popupGap = 4
function getOptionId(index : number) : string {
return optionIdPrefix + index.toString()
}
const currentValue = ref<string>(props.modelValue)
const opened = ref<boolean>(false)
const popupReady = ref<boolean>(false)
const filterEnabled = ref<boolean>(false)
const highlightedIndex = ref<number>(-1)
const popupLeft = ref<number>(0)
const popupTop = ref<number>(0)
const popupWidth = ref<number>(0)
const popupHeight = ref<number>(optionHeight + popupVerticalPadding)
const keyboardHeight = ref<number>(0)
let positionRequestId = 0
let isUnmounted = false
const inputMode = computed(() : boolean => {
return slots['input'] != null
})
const displayText = computed(() : string => {
if (currentValue.value == '') {
return props.placeholder
}
return currentValue.value
})
const visibleOptions = computed(() : string[] => {
if (!filterEnabled.value || currentValue.value == '') {
return props.options
}
return props.options.filter((item : string) : boolean => {
return item.indexOf(currentValue.value) >= 0
})
})
const optionsStyle = computed(() : string => {
return `left: ${popupLeft.value}px; top: ${popupTop.value}px; width: ${popupWidth.value}px; height: ${popupHeight.value}px;`
})
const scrollIntoViewId = ref<string>('')
function findMatchedIndex(list : string[], keyword : string) : number {
if (keyword == '') {
return -1
}
const exactIndex = list.indexOf(keyword)
if (exactIndex >= 0) {
return exactIndex
}
return list.findIndex((item : string) : boolean => {
return item.indexOf(keyword) == 0
})
}
function syncHighlight() {
const list = visibleOptions.value
if (list.length == 0) {
highlightedIndex.value = -1
return
}
const matchedIndex = findMatchedIndex(list, currentValue.value)
highlightedIndex.value = matchedIndex
}
function refreshScrollTarget() {
const targetId = highlightedIndex.value >= 0 ? getOptionId(highlightedIndex.value) : ''
scrollIntoViewId.value = ''
if (targetId == '') {
return
}
nextTick(() => {
if (opened.value) {
scrollIntoViewId.value = targetId
}
})
}
function emitValue(value : string) {
currentValue.value = value
emit('update:modelValue', value)
}
function noop() {
}
function applyPopupRect(rect : DOMRect) {
const rectTop = rect.top != null ? rect.top : 0
const rectLeft = rect.left != null ? rect.left : 0
let rectWidth = rect.width != null ? rect.width : 0
const rectHeight = rect.height != null ? rect.height : 0
const rectBottom = rect.bottom != null ? rect.bottom : rectTop + rectHeight
if (rectWidth <= 0) {
rectWidth = uni.getWindowInfo().windowWidth - 32
}
const windowInfo = uni.getWindowInfo()
const windowHeight = Math.max(0, windowInfo.windowHeight - keyboardHeight.value)
const optionCount = Math.max(1, visibleOptions.value.length)
const expectedHeight = Math.min(defaultMaxOptionsHeight, optionCount * optionHeight) + popupVerticalPadding
const minOptionsHeight = optionHeight + popupVerticalPadding
const belowSpace = windowHeight - rectBottom - viewportGap
const aboveSpace = rectTop - viewportGap
let height = expectedHeight
let top = rectBottom + popupGap
if (belowSpace < expectedHeight && aboveSpace > belowSpace) {
height = Math.max(minOptionsHeight, Math.min(expectedHeight, aboveSpace - popupGap))
top = rectTop - popupGap - height
} else {
height = Math.max(minOptionsHeight, Math.min(expectedHeight, belowSpace - popupGap))
}
popupLeft.value = rectLeft
popupTop.value = top
popupWidth.value = rectWidth
popupHeight.value = height
}
function updatePosition() {
const element = uni.getElementById(anchorId)
if (element == null) {
return
}
const rectPromise = element.getBoundingClientRectAsync()
if (rectPromise == null) {
return
}
rectPromise.then((rect : DOMRect) => {
if (isUnmounted || !opened.value) {
return
}
applyPopupRect(rect)
})
}
function updatePositionThenOpen() {
const requestId = positionRequestId + 1
positionRequestId = requestId
const element = uni.getElementById(anchorId)
if (element == null) {
if (isUnmounted || requestId != positionRequestId) {
return
}
popupReady.value = true
opened.value = true
refreshScrollTarget()
return
}
const rectPromise = element.getBoundingClientRectAsync()
if (rectPromise == null) {
if (isUnmounted || requestId != positionRequestId) {
return
}
popupReady.value = true
opened.value = true
refreshScrollTarget()
return
}
rectPromise.then((rect : DOMRect) => {
if (isUnmounted || requestId != positionRequestId) {
return
}
applyPopupRect(rect)
popupReady.value = true
opened.value = true
refreshScrollTarget()
})
}
function openOptionsWithMode(filter : boolean) {
filterEnabled.value = filter
syncHighlight()
popupReady.value = false
updatePositionThenOpen()
}
function openOptions() {
openOptionsWithMode(false)
}
function closeOptions() {
positionRequestId += 1
opened.value = false
popupReady.value = false
}
function toggleOptionsByArrow() {
if (opened.value) {
closeOptions()
return
}
openOptions()
}
function onFieldClick() {
if (inputMode.value) {
openOptionsWithMode(true)
return
}
openOptions()
}
function onInput(event : UniInputEvent) {
const value = event.detail.value
emitValue(value)
openOptionsWithMode(true)
}
function onInputFocus(_event : UniInputFocusEvent) {
openOptionsWithMode(true)
}
function onInputBlur() {
}
function onKeyboardHeightChange(res : OnKeyboardHeightChangeCallbackResult) {
if (isUnmounted) {
return
}
keyboardHeight.value = Math.max(0, res.height)
if (opened.value) {
nextTick(() => {
if (!isUnmounted && opened.value) {
updatePosition()
}
})
}
}
let keyboardListenerId = 0
onMounted(() => {
keyboardListenerId = uni.onKeyboardHeightChange(onKeyboardHeightChange)
})
onBeforeUnmount(() => {
isUnmounted = true
positionRequestId += 1
opened.value = false
popupReady.value = false
})
onUnmounted(() => {
if (keyboardListenerId != 0) {
uni.offKeyboardHeightChange(keyboardListenerId)
keyboardListenerId = 0
}
})
// #ifdef WEB
function onInputKeydown(event : KeyboardEvent) {
const key = event.key as string
if (key == 'Escape') {
event.preventDefault()
closeOptions()
return
}
if (key == 'ArrowDown') {
event.preventDefault()
if (!opened.value) {
openOptions()
return
}
if (visibleOptions.value.length == 0) {
return
}
highlightedIndex.value = Math.min(visibleOptions.value.length - 1, highlightedIndex.value + 1)
refreshScrollTarget()
return
}
if (key == 'ArrowUp') {
event.preventDefault()
if (!opened.value) {
openOptions()
return
}
if (visibleOptions.value.length == 0) {
return
}
highlightedIndex.value = Math.max(0, highlightedIndex.value - 1)
refreshScrollTarget()
return
}
if (key == 'Enter' && opened.value && highlightedIndex.value >= 0) {
event.preventDefault()
selectOption(highlightedIndex.value)
}
}
// #endif
function selectOption(index : number) {
if (index < 0 || index >= visibleOptions.value.length) {
return
}
const value = visibleOptions.value[index]
emitValue(value)
emit('selectchange', value)
closeOptions()
}
function setInputValue(value : string) {
emitValue(value)
openOptionsWithMode(true)
}
defineExpose({
setInputValue,
openOptions,
closeOptions
})
watch(() : string => props.modelValue, (value : string) => {
currentValue.value = value
if (opened.value) {
syncHighlight()
nextTick(() => {
updatePosition()
refreshScrollTarget()
})
}
})
watch(() : string[] => props.options, () => {
if (opened.value) {
syncHighlight()
nextTick(() => {
updatePosition()
refreshScrollTarget()
})
}
})
onBackPress(() : boolean => {
if (opened.value) {
closeOptions()
return true
}
return false
})
</script>
<style>
.uni-combox {
width: 100%;
}
.uni-combox__field {
width: 100%;
box-sizing: border-box;
height: 36px;
border: 1px solid #DCDFE6;
border-radius: 4px;
background-color: #ffffff;
flex-direction: row;
align-items: center;
padding-left: 10px;
}
.uni-combox__input-slot {
flex: 1;
height: 22px;
flex-direction: row;
align-items: center;
}
.uni-combox__result {
flex: 1;
height: 22px;
line-height: 22px;
padding-right: 10px;
font-size: 14px;
color: #333333;
text-overflow: ellipsis;
}
.uni-combox__result--placeholder {
color: #999999;
}
.uni-combox__arrow-wrap {
width: 28px;
height: 36px;
align-items: center;
justify-content: center;
}
.uni-combox__arrow {
width: 7px;
height: 7px;
border-right: 1px solid #999999;
border-bottom: 1px solid #999999;
transform: rotate(45deg);
}
.uni-combox__arrow--open {
transform: rotate(225deg);
}
.uni-combox__mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
z-index: 999;
background-color: rgba(0, 0, 0, 0);
}
.uni-combox__options {
position: fixed;
box-sizing: border-box;
background-color: #ffffff;
border: 1px solid #EBEEF5;
border-radius: 6px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
z-index: 1000;
overflow: hidden;
}
.uni-combox__options-scroll {
width: 100%;
height: 100%;
box-sizing: border-box;
}
.uni-combox__options-content {
width: 100%;
box-sizing: border-box;
padding-top: 4px;
padding-bottom: 4px;
}
.uni-combox__option-row {
width: 100%;
height: 36px;
flex-direction: row;
align-items: center;
}
.uni-combox__option-row--highlight {
background-color: #f9f9f9;
}
.uni-combox__option {
flex: 1;
height: 36px;
line-height: 36px;
padding-left: 10px;
padding-right: 10px;
font-size: 14px;
color: #333333;
text-overflow: ellipsis;
}
.uni-combox__option--empty {
color: #999999;
}
@media (prefers-color-scheme: dark) {
.uni-combox__field {
border-color: rgba(255, 255, 255, 0.2);
background-color: #2d2d2d;
}
.uni-combox__result {
color: #ffffff;
}
.uni-combox__option {
color: #ffffff;
}
.uni-combox__result--placeholder {
color: #8f8f8f;
}
.uni-combox__option--empty {
color: #8f8f8f;
}
.uni-combox__arrow {
border-right-color: #8f8f8f;
border-bottom-color: #8f8f8f;
}
.uni-combox__options {
background-color: #2d2d2d;
border-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
}
.uni-combox__option-row--highlight {
background-color: #3b3b3b;
}
}
</style>