mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 10:37:11 -04:00 
			
		
		
		
	feat(web): keyboard access for search dropdown, combobox fixes (#8079)
* feat(web): keyboard access for search dropdown Also: fixing cosmetic issue with combobox component. * fix: revert changing required field * fix: create new focusChange action * fix: combobox usability improvements * handle escape key on the clear button * move focus to input when clear button is clicked * leave the dropdown closed if the user has already closed the dropdown and tabs over to the clear button * activate the combobox if a user tabs backwards onto the clear button * rename focusChange to focusOutside * small fixes * do not activate combobox on backwards tabbing * simplify classes in "No results" option * prevent dropdown option from being preselected when clear button is clicked * fix: remove unused event dispatcher interface
This commit is contained in:
		
							parent
							
								
									e21c96c0ef
								
							
						
					
					
						commit
						87ccba7f9d
					
				| @ -18,6 +18,7 @@ | |||||||
|   import type { FormEventHandler } from 'svelte/elements'; |   import type { FormEventHandler } from 'svelte/elements'; | ||||||
|   import { shortcuts } from '$lib/utils/shortcut'; |   import { shortcuts } from '$lib/utils/shortcut'; | ||||||
|   import { clickOutside } from '$lib/utils/click-outside'; |   import { clickOutside } from '$lib/utils/click-outside'; | ||||||
|  |   import { focusOutside } from '$lib/utils/focus-outside'; | ||||||
| 
 | 
 | ||||||
|   /** |   /** | ||||||
|    * Unique identifier for the combobox. |    * Unique identifier for the combobox. | ||||||
| @ -40,6 +41,7 @@ | |||||||
|   let searchQuery = selectedOption?.label || ''; |   let searchQuery = selectedOption?.label || ''; | ||||||
|   let selectedIndex: number | undefined; |   let selectedIndex: number | undefined; | ||||||
|   let optionRefs: HTMLElement[] = []; |   let optionRefs: HTMLElement[] = []; | ||||||
|  |   let input: HTMLInputElement; | ||||||
|   const inputId = `combobox-${id}`; |   const inputId = `combobox-${id}`; | ||||||
|   const listboxId = `listbox-${id}`; |   const listboxId = `listbox-${id}`; | ||||||
| 
 | 
 | ||||||
| @ -51,7 +53,6 @@ | |||||||
| 
 | 
 | ||||||
|   const dispatch = createEventDispatcher<{ |   const dispatch = createEventDispatcher<{ | ||||||
|     select: ComboBoxOption | undefined; |     select: ComboBoxOption | undefined; | ||||||
|     click: void; |  | ||||||
|   }>(); |   }>(); | ||||||
| 
 | 
 | ||||||
|   const activate = () => { |   const activate = () => { | ||||||
| @ -101,6 +102,8 @@ | |||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   const onClear = () => { |   const onClear = () => { | ||||||
|  |     input?.focus(); | ||||||
|  |     selectedIndex = undefined; | ||||||
|     selectedOption = undefined; |     selectedOption = undefined; | ||||||
|     searchQuery = ''; |     searchQuery = ''; | ||||||
|     dispatch('select', selectedOption); |     dispatch('select', selectedOption); | ||||||
| @ -111,11 +114,16 @@ | |||||||
| <div | <div | ||||||
|   class="relative w-full dark:text-gray-300 text-gray-700 text-base" |   class="relative w-full dark:text-gray-300 text-gray-700 text-base" | ||||||
|   use:clickOutside={{ onOutclick: deactivate }} |   use:clickOutside={{ onOutclick: deactivate }} | ||||||
|   on:focusout={(e) => { |   use:focusOutside={{ onFocusOut: deactivate }} | ||||||
|     if (e.relatedTarget instanceof Node && !e.currentTarget.contains(e.relatedTarget)) { |   use:shortcuts={[ | ||||||
|       deactivate(); |     { | ||||||
|     } |       shortcut: { key: 'Escape' }, | ||||||
|   }} |       onShortcut: (event) => { | ||||||
|  |         event.stopPropagation(); | ||||||
|  |         closeDropdown(); | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |   ]} | ||||||
| > | > | ||||||
|   <div> |   <div> | ||||||
|     {#if isActive} |     {#if isActive} | ||||||
| @ -133,6 +141,7 @@ | |||||||
|       aria-controls={listboxId} |       aria-controls={listboxId} | ||||||
|       aria-expanded={isOpen} |       aria-expanded={isOpen} | ||||||
|       autocomplete="off" |       autocomplete="off" | ||||||
|  |       bind:this={input} | ||||||
|       class:!pl-8={isActive} |       class:!pl-8={isActive} | ||||||
|       class:!rounded-b-none={isOpen} |       class:!rounded-b-none={isOpen} | ||||||
|       class:cursor-pointer={!isActive} |       class:cursor-pointer={!isActive} | ||||||
| @ -213,9 +222,7 @@ | |||||||
|           role="option" |           role="option" | ||||||
|           aria-selected={selectedIndex === 0} |           aria-selected={selectedIndex === 0} | ||||||
|           aria-disabled={true} |           aria-disabled={true} | ||||||
|           class:bg-gray-100={selectedIndex === 0} |           class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-default aria-selected:bg-gray-100 aria-selected:dark:bg-gray-700" | ||||||
|           class:dark:bg-gray-700={selectedIndex === 0} |  | ||||||
|           class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-default" |  | ||||||
|           id={`${listboxId}-${0}`} |           id={`${listboxId}-${0}`} | ||||||
|           on:click={() => closeDropdown()} |           on:click={() => closeDropdown()} | ||||||
|         > |         > | ||||||
|  | |||||||
| @ -12,6 +12,7 @@ | |||||||
|   import { getMetadataSearchQuery } from '$lib/utils/metadata-search'; |   import { getMetadataSearchQuery } from '$lib/utils/metadata-search'; | ||||||
|   import { handlePromiseError } from '$lib/utils'; |   import { handlePromiseError } from '$lib/utils'; | ||||||
|   import { shortcut } from '$lib/utils/shortcut'; |   import { shortcut } from '$lib/utils/shortcut'; | ||||||
|  |   import { focusOutside } from '$lib/utils/focus-outside'; | ||||||
| 
 | 
 | ||||||
|   export let value = ''; |   export let value = ''; | ||||||
|   export let grayTheme: boolean; |   export let grayTheme: boolean; | ||||||
| @ -94,7 +95,7 @@ | |||||||
|   }} |   }} | ||||||
| /> | /> | ||||||
| 
 | 
 | ||||||
| <div class="w-full relative" use:clickOutside={{ onOutclick: onFocusOut }}> | <div class="w-full relative" use:clickOutside={{ onOutclick: onFocusOut }} use:focusOutside={{ onFocusOut }}> | ||||||
|   <form |   <form | ||||||
|     draggable="false" |     draggable="false" | ||||||
|     autocomplete="off" |     autocomplete="off" | ||||||
| @ -127,6 +128,7 @@ | |||||||
|         bind:value |         bind:value | ||||||
|         bind:this={input} |         bind:this={input} | ||||||
|         on:click={onFocusIn} |         on:click={onFocusIn} | ||||||
|  |         on:focus={onFocusIn} | ||||||
|         disabled={showFilter} |         disabled={showFilter} | ||||||
|         use:shortcut={{ |         use:shortcut={{ | ||||||
|           shortcut: { key: 'Escape' }, |           shortcut: { key: 'Escape' }, | ||||||
|  | |||||||
							
								
								
									
										21
									
								
								web/src/lib/utils/focus-outside.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								web/src/lib/utils/focus-outside.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,21 @@ | |||||||
|  | interface Options { | ||||||
|  |   onFocusOut?: () => void; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function focusOutside(node: HTMLElement, options: Options = {}) { | ||||||
|  |   const { onFocusOut } = options; | ||||||
|  | 
 | ||||||
|  |   const handleFocusOut = (event: FocusEvent) => { | ||||||
|  |     if (onFocusOut && event.relatedTarget instanceof Node && !node.contains(event.relatedTarget as Node)) { | ||||||
|  |       onFocusOut(); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   node.addEventListener('focusout', handleFocusOut); | ||||||
|  | 
 | ||||||
|  |   return { | ||||||
|  |     destroy() { | ||||||
|  |       node.removeEventListener('focusout', handleFocusOut); | ||||||
|  |     }, | ||||||
|  |   }; | ||||||
|  | } | ||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user