|
@@ -7,33 +7,34 @@
|
|
|
tabindex="0"
|
|
tabindex="0"
|
|
|
:aria-expanded="open"
|
|
:aria-expanded="open"
|
|
|
@click.stop="toggle"
|
|
@click.stop="toggle"
|
|
|
- @keydown.enter.prevent="toggle"
|
|
|
|
|
- @keydown.space.prevent="toggle"
|
|
|
|
|
- @keydown.esc="open = false"
|
|
|
|
|
|
|
+ @keydown="handleKeydown"
|
|
|
>
|
|
>
|
|
|
<span class="ignition-select-value" :title="selectedLabel">{{ selectedLabel }}</span>
|
|
<span class="ignition-select-value" :title="selectedLabel">{{ selectedLabel }}</span>
|
|
|
<i class="ignition-select-arrow"></i>
|
|
<i class="ignition-select-arrow"></i>
|
|
|
<Transition name="ignition-drop">
|
|
<Transition name="ignition-drop">
|
|
|
- <div v-if="open" class="ignition-select-dropdown">
|
|
|
|
|
|
|
+ <div v-if="open" ref="dropdownRef" class="ignition-select-dropdown" @click.stop>
|
|
|
|
|
+ <input v-if="options.length > 5" ref="searchRef" v-model="query" class="ignition-select-search" type="text" placeholder="搜索" @click.stop />
|
|
|
<div
|
|
<div
|
|
|
- v-for="option in options"
|
|
|
|
|
|
|
+ v-for="(option, index) in filteredOptions"
|
|
|
:key="option.value"
|
|
:key="option.value"
|
|
|
class="ignition-select-option"
|
|
class="ignition-select-option"
|
|
|
role="option"
|
|
role="option"
|
|
|
:title="option.label"
|
|
:title="option.label"
|
|
|
:aria-selected="option.value === modelValue"
|
|
:aria-selected="option.value === modelValue"
|
|
|
- :class="{ active: option.value === modelValue }"
|
|
|
|
|
|
|
+ :class="{ active: option.value === modelValue, highlighted: index === highlightIndex }"
|
|
|
|
|
+ @mouseenter="highlightIndex = index"
|
|
|
@click.stop="choose(option)"
|
|
@click.stop="choose(option)"
|
|
|
>
|
|
>
|
|
|
{{ option.label }}
|
|
{{ option.label }}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ <div v-if="!filteredOptions.length" class="ignition-select-empty">无匹配项</div>
|
|
|
</div>
|
|
</div>
|
|
|
</Transition>
|
|
</Transition>
|
|
|
</div>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<script lang="ts" setup>
|
|
<script lang="ts" setup>
|
|
|
- import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
|
|
|
|
|
|
|
+ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
|
|
import type { ManualOption } from '../data';
|
|
import type { ManualOption } from '../data';
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
const props = defineProps<{
|
|
@@ -46,13 +47,27 @@
|
|
|
}>();
|
|
}>();
|
|
|
|
|
|
|
|
const rootRef = ref<HTMLElement>();
|
|
const rootRef = ref<HTMLElement>();
|
|
|
|
|
+ const dropdownRef = ref<HTMLElement>();
|
|
|
|
|
+ const searchRef = ref<HTMLInputElement>();
|
|
|
const open = ref(false);
|
|
const open = ref(false);
|
|
|
const dropUp = ref(false);
|
|
const dropUp = ref(false);
|
|
|
|
|
+ const query = ref('');
|
|
|
|
|
+ const highlightIndex = ref(-1);
|
|
|
|
|
|
|
|
const selectedLabel = computed(() => {
|
|
const selectedLabel = computed(() => {
|
|
|
return props.options.find((option) => option.value === props.modelValue)?.label ?? '';
|
|
return props.options.find((option) => option.value === props.modelValue)?.label ?? '';
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+ const filteredOptions = computed(() => {
|
|
|
|
|
+ const keyword = query.value.trim().toLowerCase();
|
|
|
|
|
+ if (!keyword) return props.options;
|
|
|
|
|
+ return props.options.filter((option) => option.label.toLowerCase().includes(keyword));
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ watch(query, () => {
|
|
|
|
|
+ highlightIndex.value = filteredOptions.value.length ? 0 : -1;
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
function findScrollParent(element: HTMLElement | undefined): HTMLElement | null {
|
|
function findScrollParent(element: HTMLElement | undefined): HTMLElement | null {
|
|
|
let node = element?.parentElement;
|
|
let node = element?.parentElement;
|
|
|
while (node) {
|
|
while (node) {
|
|
@@ -65,30 +80,91 @@
|
|
|
return null;
|
|
return null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ function updateDropDirection() {
|
|
|
|
|
+ const rect = rootRef.value?.getBoundingClientRect();
|
|
|
|
|
+ if (!rect) return;
|
|
|
|
|
+ const scrollParent = findScrollParent(rootRef.value);
|
|
|
|
|
+ const bounds = scrollParent ? scrollParent.getBoundingClientRect() : { top: 0, bottom: window.innerHeight };
|
|
|
|
|
+ const spaceBelow = bounds.bottom - rect.bottom;
|
|
|
|
|
+ const spaceAbove = rect.top - bounds.top;
|
|
|
|
|
+ dropUp.value = spaceBelow < 220 && spaceAbove > spaceBelow;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function openDropdown() {
|
|
|
|
|
+ open.value = true;
|
|
|
|
|
+ query.value = '';
|
|
|
|
|
+ highlightIndex.value = Math.max(
|
|
|
|
|
+ 0,
|
|
|
|
|
+ props.options.findIndex((option) => option.value === props.modelValue)
|
|
|
|
|
+ );
|
|
|
|
|
+ nextTick(() => {
|
|
|
|
|
+ updateDropDirection();
|
|
|
|
|
+ if (props.options.length > 5) {
|
|
|
|
|
+ searchRef.value?.focus();
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function closeDropdown() {
|
|
|
|
|
+ open.value = false;
|
|
|
|
|
+ query.value = '';
|
|
|
|
|
+ highlightIndex.value = -1;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
function toggle() {
|
|
function toggle() {
|
|
|
- open.value = !open.value;
|
|
|
|
|
- if (open.value) {
|
|
|
|
|
- nextTick(() => {
|
|
|
|
|
- const rect = rootRef.value?.getBoundingClientRect();
|
|
|
|
|
- if (!rect) return;
|
|
|
|
|
- const scrollParent = findScrollParent(rootRef.value);
|
|
|
|
|
- const bounds = scrollParent ? scrollParent.getBoundingClientRect() : { top: 0, bottom: window.innerHeight };
|
|
|
|
|
- const spaceBelow = bounds.bottom - rect.bottom;
|
|
|
|
|
- const spaceAbove = rect.top - bounds.top;
|
|
|
|
|
- dropUp.value = spaceBelow < 220 && spaceAbove > spaceBelow;
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (open.value) closeDropdown();
|
|
|
|
|
+ else openDropdown();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function choose(option: ManualOption) {
|
|
function choose(option: ManualOption) {
|
|
|
emit('update:modelValue', option.value);
|
|
emit('update:modelValue', option.value);
|
|
|
- open.value = false;
|
|
|
|
|
|
|
+ closeDropdown();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function moveHighlight(step: number) {
|
|
|
|
|
+ const list = filteredOptions.value;
|
|
|
|
|
+ if (!list.length) {
|
|
|
|
|
+ highlightIndex.value = -1;
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ highlightIndex.value = (highlightIndex.value + step + list.length) % list.length;
|
|
|
|
|
+ nextTick(() => {
|
|
|
|
|
+ const target = dropdownRef.value?.querySelector('.ignition-select-option.highlighted');
|
|
|
|
|
+ target?.scrollIntoView({ block: 'nearest' });
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function handleKeydown(event: KeyboardEvent) {
|
|
|
|
|
+ if (event.key === 'Escape') {
|
|
|
|
|
+ closeDropdown();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!open.value) {
|
|
|
|
|
+ if (event.key === 'Enter' || event.key === 'ArrowDown') {
|
|
|
|
|
+ event.preventDefault();
|
|
|
|
|
+ openDropdown();
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (event.key === 'ArrowDown') {
|
|
|
|
|
+ event.preventDefault();
|
|
|
|
|
+ moveHighlight(1);
|
|
|
|
|
+ } else if (event.key === 'ArrowUp') {
|
|
|
|
|
+ event.preventDefault();
|
|
|
|
|
+ moveHighlight(-1);
|
|
|
|
|
+ } else if (event.key === 'Enter') {
|
|
|
|
|
+ event.preventDefault();
|
|
|
|
|
+ const option = filteredOptions.value[highlightIndex.value];
|
|
|
|
|
+ if (option) choose(option);
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function handleDocumentClick(event: MouseEvent) {
|
|
function handleDocumentClick(event: MouseEvent) {
|
|
|
if (!open.value) return;
|
|
if (!open.value) return;
|
|
|
if (rootRef.value && !rootRef.value.contains(event.target as Node)) {
|
|
if (rootRef.value && !rootRef.value.contains(event.target as Node)) {
|
|
|
- open.value = false;
|
|
|
|
|
|
|
+ closeDropdown();
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -179,6 +255,35 @@
|
|
|
bottom: calc(100% + 4px);
|
|
bottom: calc(100% + 4px);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ .ignition-select-search {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ height: 26px;
|
|
|
|
|
+ margin-bottom: 4px;
|
|
|
|
|
+ padding: 0 8px;
|
|
|
|
|
+ box-sizing: border-box;
|
|
|
|
|
+ border: 1px solid rgba(61, 246, 255, 0.35);
|
|
|
|
|
+ border-radius: 3px;
|
|
|
|
|
+ outline: none;
|
|
|
|
|
+ background: rgba(5, 28, 48, 0.85);
|
|
|
|
|
+ color: #fff;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+
|
|
|
|
|
+ &::placeholder {
|
|
|
|
|
+ color: rgba(169, 220, 244, 0.65);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ &:focus {
|
|
|
|
|
+ border-color: rgba(61, 246, 255, 0.8);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .ignition-select-empty {
|
|
|
|
|
+ padding: 8px;
|
|
|
|
|
+ color: #8fc7e8;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
.ignition-select-option {
|
|
.ignition-select-option {
|
|
|
min-height: 30px;
|
|
min-height: 30px;
|
|
|
padding: 5px 8px;
|
|
padding: 5px 8px;
|
|
@@ -189,7 +294,8 @@
|
|
|
word-break: break-all;
|
|
word-break: break-all;
|
|
|
cursor: pointer;
|
|
cursor: pointer;
|
|
|
|
|
|
|
|
- &:hover {
|
|
|
|
|
|
|
+ &:hover,
|
|
|
|
|
+ &.highlighted {
|
|
|
color: #fff;
|
|
color: #fff;
|
|
|
background: rgba(61, 246, 255, 0.16);
|
|
background: rgba(61, 246, 255, 0.16);
|
|
|
}
|
|
}
|
|
@@ -198,6 +304,10 @@
|
|
|
color: #3df6ff;
|
|
color: #3df6ff;
|
|
|
background: rgba(61, 246, 255, 0.1);
|
|
background: rgba(61, 246, 255, 0.1);
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ &.active.highlighted {
|
|
|
|
|
+ background: rgba(61, 246, 255, 0.22);
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
.ignition-drop-enter-active,
|
|
.ignition-drop-enter-active,
|