300 lines
8.5 KiB
Vue
300 lines
8.5 KiB
Vue
<template>
|
|
<div
|
|
class="dropdown"
|
|
:class="{ 'is-active': showList && filteredOptions.length }"
|
|
style="width: 100%"
|
|
>
|
|
<div class="control" style="width: 100%">
|
|
<input
|
|
v-model="model"
|
|
@focus="onFocus"
|
|
@blur="hideList"
|
|
@keydown="handleKeydown"
|
|
@input="onInput"
|
|
class="input"
|
|
:placeholder="placeholder"
|
|
autocomplete="new-password"
|
|
style="width: 100%; position: relative; z-index: 2"
|
|
:disabled="disabled"
|
|
:id="id"
|
|
/>
|
|
</div>
|
|
<div class="dropdown-menu" role="menu" style="width: 100%; z-index: 3">
|
|
<div class="dropdown-content" style="width: 100%; max-height: 10em; overflow-y: auto">
|
|
<a
|
|
v-for="(option, idx) in filteredOptions"
|
|
:key="option.value"
|
|
@mousedown.prevent="selectOption(option.value)"
|
|
:class="['dropdown-item', { 'is-active': idx === highlightedIndex }]"
|
|
style="display: flex; justify-content: space-between"
|
|
:ref="(el) => setDropdownItemRef(el, idx)"
|
|
>
|
|
<span class="has-text-weight-bold">{{ option.value }}</span>
|
|
<abbr
|
|
class="has-text-grey-light is-text-overflow"
|
|
:title="option.description"
|
|
style="margin-left: 1em"
|
|
>
|
|
{{ option.description }}
|
|
</abbr>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, watch, computed, nextTick } from 'vue';
|
|
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
options: AutoCompleteOptions;
|
|
placeholder?: string;
|
|
disabled?: boolean;
|
|
id?: string;
|
|
multiple?: boolean;
|
|
openOnFocus?: boolean;
|
|
allowShortFlags?: boolean;
|
|
}>(),
|
|
{
|
|
placeholder: '',
|
|
disabled: false,
|
|
id: '',
|
|
multiple: true,
|
|
openOnFocus: false,
|
|
allowShortFlags: false,
|
|
},
|
|
);
|
|
|
|
const model = defineModel<string>();
|
|
|
|
const onInput = () => {
|
|
showList.value = isFlagTrigger.value && filteredOptions.value.length > 0;
|
|
highlightedIndex.value = showList.value ? 0 : -1;
|
|
};
|
|
|
|
const showList = ref(false);
|
|
const highlightedIndex = ref(-1);
|
|
const dropdownItemRefs = ref<(HTMLElement | null)[]>([]);
|
|
|
|
// Extract the last non-space token and its bounds
|
|
const getLastToken = (value: string) => {
|
|
// If multiple is disabled, treat the entire input as a single token
|
|
if (!props.multiple) {
|
|
return {
|
|
token: value,
|
|
start: 0,
|
|
end: value.length,
|
|
};
|
|
}
|
|
|
|
// Multiple enabled: extract last token for multi-flag support
|
|
const m = (value || '').match(/(\S+)$/);
|
|
const token: string = m?.[1] ?? '';
|
|
const start = m ? (m.index as number) : value.length;
|
|
const end = m ? start + token.length : value.length;
|
|
return { token, start, end };
|
|
};
|
|
|
|
const filteredOptions = computed(() => {
|
|
const value = model.value || '';
|
|
if (!value) {
|
|
return props.options;
|
|
}
|
|
const { token } = getLastToken(value);
|
|
|
|
// If openOnFocus is enabled and token is empty/just whitespace, show all options
|
|
if (props.openOnFocus && !token) {
|
|
return props.options;
|
|
}
|
|
|
|
// Hide suggestions when token has '='
|
|
if (!token || token.includes('=')) {
|
|
return [];
|
|
}
|
|
|
|
// Check if token is a valid flag format
|
|
const isLongFlag = token.startsWith('--');
|
|
const isShortFlag = props.allowShortFlags && token.startsWith('-') && !token.startsWith('--');
|
|
|
|
if (!isLongFlag && !isShortFlag) {
|
|
return [];
|
|
}
|
|
|
|
// Check for exact match first - if found, only show that
|
|
const exactMatch = props.options.find((opt) => opt.value === token);
|
|
if (exactMatch) {
|
|
return [exactMatch];
|
|
}
|
|
|
|
const startsWithFlag = [];
|
|
const includesFlag = [];
|
|
const includesDesc = [];
|
|
|
|
for (const opt of props.options) {
|
|
const flag = opt.value;
|
|
const desc = opt.description.toLowerCase();
|
|
|
|
if (isShortFlag) {
|
|
// Short flags: case-sensitive matching for flag, case-insensitive for description
|
|
if (flag === token) {
|
|
startsWithFlag.push(opt);
|
|
} else if (flag.includes(token)) {
|
|
includesFlag.push(opt);
|
|
} else if (desc.includes(token.toLowerCase())) {
|
|
includesDesc.push(opt);
|
|
}
|
|
} else {
|
|
// Long flags: case-insensitive matching
|
|
const val = token.toLowerCase();
|
|
const flagLower = flag.toLowerCase();
|
|
|
|
if (flagLower.startsWith(val)) {
|
|
startsWithFlag.push(opt);
|
|
} else if (flagLower.includes(val)) {
|
|
includesFlag.push(opt);
|
|
} else if (desc.includes(val)) {
|
|
includesDesc.push(opt);
|
|
}
|
|
}
|
|
}
|
|
|
|
return [...startsWithFlag, ...includesFlag, ...includesDesc];
|
|
});
|
|
|
|
const selectOption = (val: string) => {
|
|
const value = model.value || '';
|
|
const { token, start, end } = getLastToken(value);
|
|
|
|
// If multiple is disabled, replace entire value
|
|
if (!props.multiple) {
|
|
// Preserve any '=value' suffix already typed
|
|
const eqPos = token.indexOf('=');
|
|
const after = eqPos !== -1 ? token.slice(eqPos) : '';
|
|
model.value = val + after;
|
|
showList.value = false;
|
|
highlightedIndex.value = -1;
|
|
return;
|
|
}
|
|
|
|
// Multiple enabled: replace only the last token
|
|
if (token) {
|
|
// Preserve any '=value' suffix already typed for this token
|
|
const eqPos = token.indexOf('=');
|
|
const after = eqPos !== -1 ? token.slice(eqPos) : '';
|
|
model.value = value.slice(0, start) + val + after + value.slice(end);
|
|
} else {
|
|
model.value = val;
|
|
}
|
|
showList.value = false;
|
|
highlightedIndex.value = -1;
|
|
};
|
|
|
|
const hideList = () => {
|
|
setTimeout(() => {
|
|
showList.value = false;
|
|
highlightedIndex.value = -1;
|
|
dropdownItemRefs.value = [];
|
|
}, 100);
|
|
};
|
|
|
|
const onFocus = () => {
|
|
if (!props.openOnFocus) {
|
|
return;
|
|
}
|
|
// When openOnFocus is enabled, show dropdown if there are options
|
|
const hasOptions = filteredOptions.value.length > 0;
|
|
showList.value = hasOptions;
|
|
highlightedIndex.value = hasOptions ? 0 : -1;
|
|
};
|
|
|
|
const setDropdownItemRef = (el: Element | ComponentPublicInstance | null, idx: number) => {
|
|
dropdownItemRefs.value[idx] = el instanceof HTMLElement ? el : null;
|
|
};
|
|
|
|
watch(filteredOptions, () => {
|
|
highlightedIndex.value = filteredOptions.value.length ? 0 : -1;
|
|
dropdownItemRefs.value = Array(filteredOptions.value.length).fill(null);
|
|
nextTick(() => {
|
|
const dropdown = document.querySelector('.dropdown-content');
|
|
if (dropdown) {
|
|
dropdown.scrollTop = 0;
|
|
}
|
|
});
|
|
});
|
|
|
|
const isFlagTrigger = computed(() => {
|
|
const { token } = getLastToken(model.value || '');
|
|
|
|
// If openOnFocus is enabled and input is empty, allow trigger
|
|
if (props.openOnFocus && !token) {
|
|
return true;
|
|
}
|
|
|
|
if (!token || token.includes('=')) return false;
|
|
|
|
// Check if token is a valid flag format
|
|
const isLongFlag = token.startsWith('--');
|
|
const isShortFlag = props.allowShortFlags && token.startsWith('-') && !token.startsWith('--');
|
|
|
|
if (!isLongFlag && !isShortFlag) {
|
|
return false;
|
|
}
|
|
|
|
// Allow trigger even for exact matches so users can see descriptions
|
|
return true;
|
|
});
|
|
|
|
const handleKeydown = (e: KeyboardEvent) => {
|
|
// Escape closes dropdown and lets arrows navigate the input
|
|
if (e.key === 'Escape') {
|
|
showList.value = false;
|
|
highlightedIndex.value = -1;
|
|
return;
|
|
}
|
|
if (!showList.value || !filteredOptions.value.length) {
|
|
return;
|
|
}
|
|
|
|
const pageSize = 5;
|
|
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
highlightedIndex.value = Math.min(highlightedIndex.value + 1, filteredOptions.value.length - 1);
|
|
nextTick(() => scrollHighlightedIntoView());
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0);
|
|
nextTick(() => scrollHighlightedIntoView());
|
|
} else if (e.key === 'PageDown') {
|
|
e.preventDefault();
|
|
highlightedIndex.value = Math.min(
|
|
highlightedIndex.value + pageSize,
|
|
filteredOptions.value.length - 1,
|
|
);
|
|
nextTick(() => scrollHighlightedIntoView());
|
|
} else if (e.key === 'PageUp') {
|
|
e.preventDefault();
|
|
highlightedIndex.value = Math.max(highlightedIndex.value - pageSize, 0);
|
|
nextTick(() => scrollHighlightedIntoView());
|
|
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
|
const selected =
|
|
highlightedIndex.value >= 0 && highlightedIndex.value < filteredOptions.value.length
|
|
? filteredOptions.value[highlightedIndex.value]
|
|
: undefined;
|
|
if (selected && isFlagTrigger.value) {
|
|
e.preventDefault();
|
|
selectOption(selected.value);
|
|
}
|
|
}
|
|
};
|
|
|
|
function scrollHighlightedIntoView() {
|
|
const el = dropdownItemRefs.value[highlightedIndex.value];
|
|
if (!el) {
|
|
return;
|
|
}
|
|
el.scrollIntoView({ block: 'nearest' });
|
|
}
|
|
</script>
|