@mozaic-ds/vue
Version:
Mozaic-Vue is the Vue.js implementation of ADEO Design system
148 lines (127 loc) • 3.87 kB
text/typescript
import { computed, nextTick, ref, watch, type Ref, type ShallowRef } from 'vue';
import type { ListboxOption } from './MOptionListbox.types';
type Props = {
open?: boolean;
id: string;
};
type Deps = {
filteredResults: Ref<ListboxOption[]>;
optionsList: Readonly<ShallowRef<HTMLElement | null>>;
isSelectable: (item: ListboxOption) => boolean;
isOptionSelected: (item: ListboxOption) => boolean;
toggleValue: (item: ListboxOption) => void;
toggleSection: (item: ListboxOption) => void;
};
export function useListboxNavigation(
props: Props,
emit: (on: 'open' | 'close') => void,
deps: Deps,
) {
const activeIndex = ref<number>(-1);
const activeDescendantId = computed(() =>
activeIndex.value >= 0
? `option-${props.id}-${activeIndex.value}`
: undefined,
);
function moveActive(delta: number) {
if (!props.open || deps.filteredResults.value.length === 0) return;
const total = deps.filteredResults.value.length;
let nextIndex = activeIndex.value;
for (let i = 0; i < total; i++) {
nextIndex += delta;
if (nextIndex < 0) nextIndex = total - 1;
if (nextIndex >= total) nextIndex = 0;
if (deps.isSelectable(deps.filteredResults.value[nextIndex])) {
activeIndex.value = nextIndex;
return;
}
}
}
function getFirstSelectableIndex() {
return deps.filteredResults.value.findIndex((item) =>
deps.isSelectable(item),
);
}
function getLastSelectableIndex() {
for (let i = deps.filteredResults.value.length - 1; i >= 0; i--) {
if (deps.isSelectable(deps.filteredResults.value[i])) return i;
}
return -1;
}
function getSelectedOptionIndex() {
return deps.filteredResults.value.findIndex(
(item) =>
item.type !== 'section' &&
deps.isSelectable(item) &&
deps.isOptionSelected(item),
);
}
function getInitialActiveIndex(direction: 'forward' | 'backward') {
const selectedIndex = getSelectedOptionIndex();
if (selectedIndex >= 0) return selectedIndex;
return direction === 'backward'
? getLastSelectableIndex()
: getFirstSelectableIndex();
}
function selectActive() {
const item = deps.filteredResults.value[activeIndex.value];
if (!item || !deps.isSelectable(item)) return;
if (item.type === 'section') {
deps.toggleSection(item);
} else {
deps.toggleValue(item);
}
}
function scrollActiveOptionIntoView() {
if (!props.open || activeIndex.value < 0) return;
const activeOption = deps.optionsList.value?.querySelector<HTMLElement>(
`#option-${props.id}-${activeIndex.value}`,
);
activeOption?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
function handleKeydown(event: KeyboardEvent) {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
if (!props.open) {
emit('open');
activeIndex.value = getInitialActiveIndex('forward');
} else {
moveActive(1);
}
break;
case 'ArrowUp':
event.preventDefault();
if (!props.open) {
emit('open');
activeIndex.value = getInitialActiveIndex('backward');
} else {
moveActive(-1);
}
break;
case 'Enter':
event.preventDefault();
if (!props.open) {
emit('open');
activeIndex.value = getInitialActiveIndex('forward');
} else {
selectActive();
}
break;
case 'Escape':
event.preventDefault();
emit('close');
break;
}
}
watch([activeIndex, () => props.open], async ([index, isOpen]) => {
if (!isOpen || index < 0) return;
await nextTick();
scrollActiveOptionIntoView();
});
return {
activeIndex,
activeDescendantId,
handleKeydown,
};
}