11<script setup lang="ts">
2- import { computed , onBeforeUnmount , onMounted , ref } from " vue" ;
2+ import { computed , onBeforeUnmount , onMounted , ref , watch } from " vue" ;
33
44import type { Option } from " ./types" ;
55import ChevronDownIcon from " ./icons/ChevronDownIcon.vue" ;
@@ -45,6 +45,13 @@ const props = withDefaults(
4545 * JavaScript, instead of using CSS absolute & relative positioning.
4646 */
4747 teleport? : string ;
48+ /**
49+ * ARIA attributes to describe the select component. This is useful for accessibility.
50+ */
51+ aria? : {
52+ labelledby? : string ;
53+ required? : boolean ;
54+ };
4855 /**
4956 * A function to get the label of an option. By default, it assumes the option is an
5057 * object with a `label` property. Used to display the selected option in the input &
@@ -70,6 +77,7 @@ const props = withDefaults(
7077 isMulti: false ,
7178 closeOnSelect: true ,
7279 teleport: undefined ,
80+ aria: undefined ,
7381 getOptionLabel : (option : Option ) => option .label ,
7482 getMultiValueLabel : (option : Option ) => option .label ,
7583 },
@@ -90,8 +98,9 @@ const selected = defineModel<string | string[]>({
9098 },
9199});
92100
93- const container = ref <HTMLElement | null >(null );
101+ const container = ref <HTMLDivElement | null >(null );
94102const input = ref <HTMLInputElement | null >(null );
103+ const menu = ref <HTMLDivElement | null >(null );
95104
96105const search = ref (" " );
97106const menuOpen = ref (false );
@@ -121,7 +130,7 @@ const filteredOptions = computed(() => {
121130
122131const selectedOptions = computed (() => {
123132 if (props .isMulti ) {
124- return props .options .filter ((option ) => ( selected .value as string []). includes ( option . value ));
133+ return ( selected . value as string []). map (( value ) => props .options .find ((option ) => option .value === value )! );
125134 }
126135
127136 const found = props .options .find ((option ) => option .value === selected .value );
@@ -138,10 +147,9 @@ const openMenu = (options?: { focusInput?: boolean }) => {
138147 }
139148};
140149
141- const focusInput = () => {
142- if (input .value ) {
143- input .value .focus ();
144- }
150+ const closeMenu = () => {
151+ menuOpen .value = false ;
152+ search .value = " " ;
145153};
146154
147155const setOption = (value : string ) => {
@@ -202,11 +210,43 @@ const handleNavigation = (e: KeyboardEvent) => {
202210 setOption (filteredOptions .value [focusedOption .value ].value );
203211 }
204212
213+ // When pressing space with menu open but no search, select the focused option.
214+ if (e .code === " Space" && search .value .length === 0 ) {
215+ e .preventDefault ();
216+ setOption (filteredOptions .value [focusedOption .value ].value );
217+ }
218+
205219 if (e .key === " Escape" ) {
206220 e .preventDefault ();
207221 menuOpen .value = false ;
208222 search .value = " " ;
209223 }
224+
225+ // When pressing backspace with no search, remove the last selected option.
226+ if (e .key === " Backspace" && search .value .length === 0 && selected .value .length > 0 ) {
227+ e .preventDefault ();
228+
229+ if (props .isMulti ) {
230+ selected .value = (selected .value as string []).slice (0 , - 1 );
231+ }
232+ else {
233+ selected .value = " " ;
234+ }
235+ }
236+ }
237+ };
238+
239+ /**
240+ * When pressing space inside the input, open the menu only if the search is
241+ * empty. Otherwise, the user is typing and we should skip this action.
242+ *
243+ * @param e KeyboardEvent
244+ */
245+ const handleInputSpace = (e : KeyboardEvent ) => {
246+ if (! menuOpen .value && search .value .length === 0 ) {
247+ e .preventDefault ();
248+ e .stopImmediatePropagation ();
249+ openMenu ();
210250 }
211251};
212252
@@ -232,6 +272,16 @@ const calculateMenuPosition = () => {
232272 return { top: " 0px" , left: " 0px" };
233273};
234274
275+ // When focusing the input and typing, open the menu automatically.
276+ watch (
277+ () => search .value ,
278+ () => {
279+ if (search .value && ! menuOpen .value ) {
280+ openMenu ();
281+ }
282+ },
283+ );
284+
235285onMounted (() => {
236286 document .addEventListener (" click" , handleClickOutside );
237287 document .addEventListener (" keydown" , handleNavigation );
@@ -256,12 +306,16 @@ onBeforeUnmount(() => {
256306 :class =" { multi: isMulti }"
257307 role =" combobox"
258308 :aria-expanded =" menuOpen"
259- :aria-label =" placeholder"
309+ :aria-describedby =" placeholder"
310+ :aria-description =" placeholder"
311+ :aria-labelledby =" aria?.labelledby"
312+ :aria-label =" selectedOptions.length ? selectedOptions.map(getOptionLabel).join(', ') : ''"
313+ :aria-required =" aria?.required"
260314 >
261315 <div
262316 v-if =" !props.isMulti && selectedOptions[0]"
263317 class =" single-value"
264- @click =" focusInput "
318+ @click =" input?.focus() "
265319 >
266320 <slot name =" value" :option =" selectedOptions[0]" >
267321 {{ getOptionLabel(selectedOptions[0]) }}
@@ -294,7 +348,9 @@ onBeforeUnmount(() => {
294348 tabindex =" 0"
295349 :disabled =" isDisabled"
296350 :placeholder =" selectedOptions.length === 0 ? placeholder : ''"
297- @focus =" openMenu({ focusInput: false })"
351+ @mousedown =" openMenu()"
352+ @keydown.tab =" closeMenu"
353+ @keydown.space =" handleInputSpace"
298354 >
299355 </div >
300356
@@ -329,7 +385,11 @@ onBeforeUnmount(() => {
329385 <Teleport :to =" teleport" :disabled =" !teleport" >
330386 <div
331387 v-if =" menuOpen"
388+ ref =" menu"
332389 class =" menu"
390+ role =" listbox"
391+ :aria-label =" aria?.labelledby"
392+ :aria-multiselectable =" isMulti"
333393 :style =" {
334394 width: props.teleport ? `${container?.getBoundingClientRect().width}px` : '100%',
335395 top: props.teleport ? calculateMenuPosition().top : 'unset',
@@ -342,6 +402,8 @@ onBeforeUnmount(() => {
342402 type =" button"
343403 class =" menu-option"
344404 :class =" { focused: focusedOption === i, selected: option.value === selected }"
405+ :menu =" menu"
406+ :index =" i"
345407 :is-focused =" focusedOption === i"
346408 :is-selected =" option.value === selected"
347409 @select =" setOption(option.value)"
0 commit comments