ytptube/ui/app/components/InputAutocomplete.vue
2026-02-20 22:46:30 +03:00

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>