<script setup lang="ts">
|
import { computed, nextTick, onBeforeUnmount, onDeactivated, ref, useId } from 'vue';
|
import Icon from '#/mobile/icon.vue';
|
|
export interface Choice { id?: number; name?: string; code?: string }
|
const props = withDefaults(defineProps<{
|
modelValue?: number; label: string; options: Choice[]; disabled?: boolean;
|
loading?: boolean; placeholder?: string; allowEmpty?: boolean;
|
}>(), { placeholder: '请选择', allowEmpty: false });
|
const emit = defineEmits<{ 'update:modelValue': [value: number | undefined] }>();
|
const dialog = ref<HTMLDialogElement>(), searchInput = ref<HTMLInputElement>();
|
const search = ref(''), opened = ref(false), titleId = useId();
|
const selected = computed(() => props.options.find(o => o.id === props.modelValue));
|
const filtered = computed(() => {
|
const key = search.value.trim().toLocaleLowerCase();
|
return props.options.filter(o => o.id != null && (!key || [o.name, o.code].some(v => v?.toLocaleLowerCase().includes(key))));
|
});
|
let previousOverflow = '';
|
async function open() {
|
if (props.disabled || props.loading || opened.value) return;
|
search.value = ''; opened.value = true;
|
previousOverflow = document.body.style.overflow;
|
document.body.style.overflow = 'hidden';
|
await nextTick();
|
dialog.value?.showModal();
|
searchInput.value?.focus({ preventScroll: true });
|
}
|
function close() {
|
if (!opened.value) return;
|
dialog.value?.close(); opened.value = false;
|
document.body.style.overflow = previousOverflow;
|
}
|
function choose(value?: number) { emit('update:modelValue', value); close(); }
|
function trapFocus(event: KeyboardEvent) {
|
if(event.key!=='Tab')return;
|
const controls=Array.from(dialog.value?.querySelectorAll<HTMLElement>('button:not(:disabled),input:not(:disabled)')||[])
|
.filter(el=>el.getClientRects().length>0);
|
const first=controls[0],last=controls[controls.length-1];
|
if(!first||!last)return;
|
if(event.shiftKey&&document.activeElement===first){event.preventDefault();last.focus();}
|
else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first.focus();}
|
}
|
function backdrop(e: MouseEvent) {
|
if (e.target !== dialog.value) return;
|
const rect = dialog.value.getBoundingClientRect();
|
if (e.clientY < rect.top || e.clientX < rect.left || e.clientX > rect.right) close();
|
}
|
onBeforeUnmount(close);
|
onDeactivated(close);
|
</script>
|
<template>
|
<div class="m-field">
|
<span :id="titleId+'-label'">{{label}}</span>
|
<button type="button" class="m-picker-trigger" :disabled="disabled||loading" aria-haspopup="dialog"
|
:aria-labelledby="titleId+'-label '+titleId+'-value'" :aria-expanded="opened" @click="open">
|
<span :id="titleId+'-value'">{{loading?'正在加载…':selected?.name||placeholder}}</span><Icon name="arrow"/>
|
</button>
|
</div>
|
<dialog ref="dialog" class="m-picker-dialog" :aria-labelledby="titleId" @cancel.prevent="close" @click="backdrop" @keydown="trapFocus">
|
<div class="m-picker-body">
|
<header class="m-picker-header"><h2 :id="titleId" class="m-title">选择{{label}}</h2><button type="button" class="m-icon-button" :aria-label="'关闭'+label+'选择'" @click="close">×</button></header>
|
<label class="m-field"><span>搜索{{label}}</span><input ref="searchInput" v-model="search" class="m-input" :placeholder="'输入'+label+'名称或编码'" autocomplete="off"/></label>
|
<div class="m-picker-options" :aria-label="label+'选项'">
|
<button v-if="allowEmpty" type="button" class="m-picker-option" :aria-pressed="modelValue==null" @click="choose()">{{placeholder}}<span v-if="modelValue==null">✓</span></button>
|
<button v-for="option in filtered" :key="option.id" type="button" class="m-picker-option" :aria-pressed="option.id===modelValue" @click="choose(option.id)">
|
<span>{{option.name||option.code||option.id}}<small v-if="option.code&&option.code!==option.name">{{option.code}}</small></span><span v-if="option.id===modelValue">✓</span>
|
</button>
|
<p v-if="!filtered.length" class="m-empty">{{options.length?'没有匹配的'+label:'暂无可选'+label}}</p>
|
</div>
|
</div>
|
</dialog>
|
</template>
|