Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ Teleport the menu outside of the component DOM tree. You can pass a valid string

**Note**: top and left properties are calculated using a ref on the `.vue-select` with a `container.getBoundingClientRect()`.

**aria**: `{ labelledby?: string; required?: boolean; }` (default: `undefined`)

Aria attributes to be passed to the select control to improve accessibility.

**getOptionLabel**: `(option: Option) => string` (default: `option => option.label`)

A function to get the label of an option. This is useful when you want to use a property different from `label` as the label of the option.
Expand Down
39 changes: 25 additions & 14 deletions src/MenuOption.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { nextTick, ref, watch } from "vue";
import { ref, watch } from "vue";

const props = defineProps<{
menu: HTMLDivElement | null;
index: number;
isFocused: boolean;
isSelected: boolean;
}>();
Expand All @@ -15,29 +17,38 @@ const option = ref<HTMLButtonElement | null>(null);
// Scroll the focused option into view when it's out of the menu's viewport.
watch(
() => props.isFocused,
async () => {
if (props.isFocused) {
// Use nextTick to wait for the next DOM render.
await nextTick(() => {
option.value?.parentElement?.scrollTo({
top: option.value?.offsetTop - option.value?.parentElement?.offsetHeight + option.value?.offsetHeight,
behavior: "instant",
});
});
() => {
if (props.isFocused && props.menu) {
// Get child element with index
const option = props.menu.children[props.index] as HTMLDivElement;

const optionTop = option.offsetTop;
const optionBottom = optionTop + option.clientHeight;
const menuScrollTop = props.menu.scrollTop;
const menuHeight = props.menu.clientHeight;

if (optionTop < menuScrollTop) {
// eslint-disable-next-line vue/no-mutating-props
props.menu.scrollTop = optionTop;
}
else if (optionBottom > menuScrollTop + menuHeight) {
// eslint-disable-next-line vue/no-mutating-props
props.menu.scrollTop = optionBottom - menuHeight;
}
}
},
);
</script>

<template>
<button
<div
ref="option"
type="button"
class="menu-option"
tabindex="-1"
role="option"
:class="{ focused: isFocused, selected: isSelected }"
:aria-disabled="false"
@click="emit('select')"
>
<slot />
</button>
</div>
</template>
82 changes: 72 additions & 10 deletions src/Select.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";

import type { Option } from "./types";
import ChevronDownIcon from "./icons/ChevronDownIcon.vue";
Expand Down Expand Up @@ -45,6 +45,13 @@ const props = withDefaults(
* JavaScript, instead of using CSS absolute & relative positioning.
*/
teleport?: string;
/**
* ARIA attributes to describe the select component. This is useful for accessibility.
*/
aria?: {
labelledby?: string;
required?: boolean;
};
/**
* A function to get the label of an option. By default, it assumes the option is an
* object with a `label` property. Used to display the selected option in the input &
Expand All @@ -70,6 +77,7 @@ const props = withDefaults(
isMulti: false,
closeOnSelect: true,
teleport: undefined,
aria: undefined,
getOptionLabel: (option: Option) => option.label,
getMultiValueLabel: (option: Option) => option.label,
},
Expand All @@ -90,8 +98,9 @@ const selected = defineModel<string | string[]>({
},
});

const container = ref<HTMLElement | null>(null);
const container = ref<HTMLDivElement | null>(null);
const input = ref<HTMLInputElement | null>(null);
const menu = ref<HTMLDivElement | null>(null);

const search = ref("");
const menuOpen = ref(false);
Expand Down Expand Up @@ -121,7 +130,7 @@ const filteredOptions = computed(() => {

const selectedOptions = computed(() => {
if (props.isMulti) {
return props.options.filter((option) => (selected.value as string[]).includes(option.value));
return (selected.value as string[]).map((value) => props.options.find((option) => option.value === value)!);
}

const found = props.options.find((option) => option.value === selected.value);
Expand All @@ -138,10 +147,9 @@ const openMenu = (options?: { focusInput?: boolean }) => {
}
};

const focusInput = () => {
if (input.value) {
input.value.focus();
}
const closeMenu = () => {
menuOpen.value = false;
search.value = "";
};

const setOption = (value: string) => {
Expand Down Expand Up @@ -202,11 +210,43 @@ const handleNavigation = (e: KeyboardEvent) => {
setOption(filteredOptions.value[focusedOption.value].value);
}

// When pressing space with menu open but no search, select the focused option.
if (e.code === "Space" && search.value.length === 0) {
e.preventDefault();
setOption(filteredOptions.value[focusedOption.value].value);
}

if (e.key === "Escape") {
e.preventDefault();
menuOpen.value = false;
search.value = "";
}

// When pressing backspace with no search, remove the last selected option.
if (e.key === "Backspace" && search.value.length === 0 && selected.value.length > 0) {
e.preventDefault();

if (props.isMulti) {
selected.value = (selected.value as string[]).slice(0, -1);
}
else {
selected.value = "";
}
}
}
};

/**
* When pressing space inside the input, open the menu only if the search is
* empty. Otherwise, the user is typing and we should skip this action.
*
* @param e KeyboardEvent
*/
const handleInputSpace = (e: KeyboardEvent) => {
if (!menuOpen.value && search.value.length === 0) {
e.preventDefault();
e.stopImmediatePropagation();
openMenu();
}
};

Expand All @@ -232,6 +272,16 @@ const calculateMenuPosition = () => {
return { top: "0px", left: "0px" };
};

// When focusing the input and typing, open the menu automatically.
watch(
() => search.value,
() => {
if (search.value && !menuOpen.value) {
openMenu();
}
},
);

onMounted(() => {
document.addEventListener("click", handleClickOutside);
document.addEventListener("keydown", handleNavigation);
Expand All @@ -256,12 +306,16 @@ onBeforeUnmount(() => {
:class="{ multi: isMulti }"
role="combobox"
:aria-expanded="menuOpen"
:aria-label="placeholder"
:aria-describedby="placeholder"
:aria-description="placeholder"
:aria-labelledby="aria?.labelledby"
:aria-label="selectedOptions.length ? selectedOptions.map(getOptionLabel).join(', ') : ''"
:aria-required="aria?.required"
>
<div
v-if="!props.isMulti && selectedOptions[0]"
class="single-value"
@click="focusInput"
@click="input?.focus()"
>
<slot name="value" :option="selectedOptions[0]">
{{ getOptionLabel(selectedOptions[0]) }}
Expand Down Expand Up @@ -294,7 +348,9 @@ onBeforeUnmount(() => {
tabindex="0"
:disabled="isDisabled"
:placeholder="selectedOptions.length === 0 ? placeholder : ''"
@focus="openMenu({ focusInput: false })"
@mousedown="openMenu()"
@keydown.tab="closeMenu"
@keydown.space="handleInputSpace"
>
</div>

Expand Down Expand Up @@ -329,7 +385,11 @@ onBeforeUnmount(() => {
<Teleport :to="teleport" :disabled="!teleport">
<div
v-if="menuOpen"
ref="menu"
class="menu"
role="listbox"
:aria-label="aria?.labelledby"
:aria-multiselectable="isMulti"
:style="{
width: props.teleport ? `${container?.getBoundingClientRect().width}px` : '100%',
top: props.teleport ? calculateMenuPosition().top : 'unset',
Expand All @@ -342,6 +402,8 @@ onBeforeUnmount(() => {
type="button"
class="menu-option"
:class="{ focused: focusedOption === i, selected: option.value === selected }"
:menu="menu"
:index="i"
:is-focused="focusedOption === i"
:is-selected="option.value === selected"
@select="setOption(option.value)"
Expand Down
2 changes: 1 addition & 1 deletion website/Website.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import CustomOption from "./CustomOption.vue";
<a
href="https://github.com/TotomInc/vue3-select-component"
target="_blank"
class="mt-4 border border-neutral-200 bg-white rounded text-sm font-medium px-4 py-2 text-neutral-950 flex items-center self-start mx-auto hover:bg-neutral-50 focus:outline-none gap-1.5"
class="mt-4 border border-neutral-200 bg-white rounded text-sm font-medium px-4 py-2 text-neutral-950 flex items-center self-start mx-auto hover:bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-1 gap-1.5"
>
View docs on GitHub
<svg
Expand Down