mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-04 03:39:37 -05: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