mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-04 03:27:09 -05:00 
			
		
		
		
	refactor(web): list navigation with keyboard (#7987)
This commit is contained in:
		
							parent
							
								
									e21c586cc5
								
							
						
					
					
						commit
						997e9c5877
					
				@ -1,7 +1,7 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
  import { createEventDispatcher } from 'svelte';
 | 
			
		||||
  import ConfirmDialogue from './confirm-dialogue.svelte';
 | 
			
		||||
  import { maximumLengthSearchPeople, timeBeforeShowLoadingSpinner } from '$lib/constants';
 | 
			
		||||
  import { timeBeforeShowLoadingSpinner } from '$lib/constants';
 | 
			
		||||
  import { handleError } from '$lib/utils/handle-error';
 | 
			
		||||
 | 
			
		||||
  import { clickOutside } from '$lib/utils/click-outside';
 | 
			
		||||
@ -10,6 +10,7 @@
 | 
			
		||||
  import { timeToLoadTheMap } from '$lib/constants';
 | 
			
		||||
  import { searchPlaces, type AssetResponseDto, type PlacesResponseDto } from '@immich/sdk';
 | 
			
		||||
  import SearchBar from '../elements/search-bar.svelte';
 | 
			
		||||
  import { listNavigation } from '$lib/utils/list-navigation';
 | 
			
		||||
 | 
			
		||||
  export const title = 'Change Location';
 | 
			
		||||
  export let asset: AssetResponseDto | undefined = undefined;
 | 
			
		||||
@ -24,8 +25,7 @@
 | 
			
		||||
  let searchWord: string;
 | 
			
		||||
  let isSearching = false;
 | 
			
		||||
  let showSpinner = false;
 | 
			
		||||
  let focusedElements: (HTMLButtonElement | null)[] = Array.from({ length: maximumLengthSearchPeople }, () => null);
 | 
			
		||||
  let indexFocus: number | null = null;
 | 
			
		||||
  let suggestionContainer: HTMLDivElement;
 | 
			
		||||
  let hideSuggestion = false;
 | 
			
		||||
  let addClipMapMarker: (long: number, lat: number) => void;
 | 
			
		||||
 | 
			
		||||
@ -41,7 +41,6 @@
 | 
			
		||||
  $: {
 | 
			
		||||
    if (places) {
 | 
			
		||||
      suggestedPlaces = places.slice(0, 5);
 | 
			
		||||
      indexFocus = null;
 | 
			
		||||
    }
 | 
			
		||||
    if (searchWord === '') {
 | 
			
		||||
      suggestedPlaces = [];
 | 
			
		||||
@ -93,52 +92,8 @@
 | 
			
		||||
    point = { lng: longitude, lat: latitude };
 | 
			
		||||
    addClipMapMarker(longitude, latitude);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleKeyboardPress = (event: KeyboardEvent) => {
 | 
			
		||||
    if (suggestedPlaces.length === 0) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    event.stopPropagation();
 | 
			
		||||
    switch (event.key) {
 | 
			
		||||
      case 'ArrowDown': {
 | 
			
		||||
        event.preventDefault();
 | 
			
		||||
        if (indexFocus === null) {
 | 
			
		||||
          indexFocus = 0;
 | 
			
		||||
        } else if (indexFocus === suggestedPlaces.length - 1) {
 | 
			
		||||
          indexFocus = 0;
 | 
			
		||||
        } else {
 | 
			
		||||
          indexFocus++;
 | 
			
		||||
        }
 | 
			
		||||
        focusedElements[indexFocus]?.focus();
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      case 'ArrowUp': {
 | 
			
		||||
        if (indexFocus === null) {
 | 
			
		||||
          indexFocus = 0;
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
        if (indexFocus === 0) {
 | 
			
		||||
          indexFocus = suggestedPlaces.length - 1;
 | 
			
		||||
        } else {
 | 
			
		||||
          indexFocus--;
 | 
			
		||||
        }
 | 
			
		||||
        focusedElements[indexFocus]?.focus();
 | 
			
		||||
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      case 'Enter': {
 | 
			
		||||
        if (indexFocus !== null) {
 | 
			
		||||
          hideSuggestion = true;
 | 
			
		||||
          handleUseSuggested(suggestedPlaces[indexFocus].latitude, suggestedPlaces[indexFocus].longitude);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<svelte:document on:keydown={handleKeyboardPress} />
 | 
			
		||||
 | 
			
		||||
<ConfirmDialogue
 | 
			
		||||
  confirmColor="primary"
 | 
			
		||||
  cancelColor="secondary"
 | 
			
		||||
@ -148,7 +103,11 @@
 | 
			
		||||
  onClose={handleCancel}
 | 
			
		||||
>
 | 
			
		||||
  <div slot="prompt" class="flex flex-col w-full h-full gap-2">
 | 
			
		||||
    <div class="relative w-64 sm:w-96" use:clickOutside on:outclick={() => (hideSuggestion = true)}>
 | 
			
		||||
    <div
 | 
			
		||||
      class="relative w-64 sm:w-96"
 | 
			
		||||
      use:clickOutside={{ onOutclick: () => (hideSuggestion = true) }}
 | 
			
		||||
      use:listNavigation={suggestionContainer}
 | 
			
		||||
    >
 | 
			
		||||
      <button class="w-full" on:click={() => (hideSuggestion = false)}>
 | 
			
		||||
        <SearchBar
 | 
			
		||||
          placeholder="Search places"
 | 
			
		||||
@ -161,11 +120,10 @@
 | 
			
		||||
          roundedBottom={suggestedPlaces.length === 0 || hideSuggestion}
 | 
			
		||||
        />
 | 
			
		||||
      </button>
 | 
			
		||||
      <div class="absolute z-[99] w-full" id="suggestion">
 | 
			
		||||
      <div class="absolute z-[99] w-full" id="suggestion" bind:this={suggestionContainer}>
 | 
			
		||||
        {#if !hideSuggestion}
 | 
			
		||||
          {#each suggestedPlaces as place, index}
 | 
			
		||||
            <button
 | 
			
		||||
              bind:this={focusedElements[index]}
 | 
			
		||||
              class=" flex w-full border-t border-gray-400 dark:border-immich-dark-gray h-14 place-items-center bg-gray-200 p-2 dark:bg-gray-700 hover:bg-gray-300 hover:dark:bg-[#232932] focus:bg-gray-300 focus:dark:bg-[#232932] {index ===
 | 
			
		||||
              suggestedPlaces.length - 1
 | 
			
		||||
                ? 'rounded-b-lg border-b'
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										32
									
								
								web/src/lib/utils/list-navigation.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								web/src/lib/utils/list-navigation.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,32 @@
 | 
			
		||||
import type { Action } from 'svelte/action';
 | 
			
		||||
import { shortcuts } from './shortcut';
 | 
			
		||||
 | 
			
		||||
export const listNavigation: Action<HTMLElement, HTMLElement> = (node, container: HTMLElement) => {
 | 
			
		||||
  const moveFocus = (direction: 'up' | 'down') => {
 | 
			
		||||
    const children = Array.from(container?.children);
 | 
			
		||||
    if (children.length === 0) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const currentIndex = document.activeElement === null ? -1 : children.indexOf(document.activeElement);
 | 
			
		||||
    const directionFactor = (direction === 'up' ? -1 : 1) + (direction === 'up' && currentIndex === -1 ? 1 : 0);
 | 
			
		||||
    const newIndex = (currentIndex + directionFactor + children.length) % children.length;
 | 
			
		||||
 | 
			
		||||
    const element = children.at(newIndex);
 | 
			
		||||
    if (element instanceof HTMLElement) {
 | 
			
		||||
      element.focus();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const { destroy } = shortcuts(node, [
 | 
			
		||||
    { shortcut: { key: 'ArrowUp' }, onShortcut: () => moveFocus('up'), ignoreInputFields: false },
 | 
			
		||||
    { shortcut: { key: 'ArrowDown' }, onShortcut: () => moveFocus('down'), ignoreInputFields: false },
 | 
			
		||||
  ]);
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    update(newContainer) {
 | 
			
		||||
      container = newContainer;
 | 
			
		||||
    },
 | 
			
		||||
    destroy,
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
@ -10,6 +10,7 @@ export type Shortcut = {
 | 
			
		||||
 | 
			
		||||
export type ShortcutOptions<T = HTMLElement> = {
 | 
			
		||||
  shortcut: Shortcut;
 | 
			
		||||
  ignoreInputFields?: boolean;
 | 
			
		||||
  onShortcut: (event: KeyboardEvent & { currentTarget: T }) => unknown;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -50,11 +51,13 @@ export const shortcuts = <T extends HTMLElement>(
 | 
			
		||||
  options: ShortcutOptions<T>[],
 | 
			
		||||
): ActionReturn<ShortcutOptions<T>[]> => {
 | 
			
		||||
  function onKeydown(event: KeyboardEvent) {
 | 
			
		||||
    if (shouldIgnoreShortcut(event)) {
 | 
			
		||||
      return;
 | 
			
		||||
    const ignoreShortcut = shouldIgnoreShortcut(event);
 | 
			
		||||
 | 
			
		||||
    for (const { shortcut, onShortcut, ignoreInputFields = true } of options) {
 | 
			
		||||
      if (ignoreInputFields && ignoreShortcut) {
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
    for (const { shortcut, onShortcut } of options) {
 | 
			
		||||
      if (matchesShortcut(event, shortcut)) {
 | 
			
		||||
        event.preventDefault();
 | 
			
		||||
        onShortcut(event as KeyboardEvent & { currentTarget: T });
 | 
			
		||||
 | 
			
		||||
@ -47,6 +47,7 @@
 | 
			
		||||
  import { mdiArrowLeft, mdiDotsVertical, mdiPlus } from '@mdi/js';
 | 
			
		||||
  import { onMount } from 'svelte';
 | 
			
		||||
  import type { PageData } from './$types';
 | 
			
		||||
  import { listNavigation } from '$lib/utils/list-navigation';
 | 
			
		||||
 | 
			
		||||
  export let data: PageData;
 | 
			
		||||
 | 
			
		||||
@ -95,8 +96,7 @@
 | 
			
		||||
   **/
 | 
			
		||||
  let searchWord: string;
 | 
			
		||||
  let isSearchingPeople = false;
 | 
			
		||||
  let focusedElements: (HTMLButtonElement | null)[] = Array.from({ length: maximumLengthSearchPeople }, () => null);
 | 
			
		||||
  let indexFocus: number | null = null;
 | 
			
		||||
  let suggestionContainer: HTMLDivElement;
 | 
			
		||||
 | 
			
		||||
  const searchPeople = async () => {
 | 
			
		||||
    if ((people.length < maximumLengthSearchPeople && name.startsWith(searchWord)) || name === '') {
 | 
			
		||||
@ -122,7 +122,6 @@
 | 
			
		||||
  $: {
 | 
			
		||||
    if (people) {
 | 
			
		||||
      suggestedPeople = name ? searchNameLocal(name, people, 5, data.person.id) : [];
 | 
			
		||||
      indexFocus = null;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -143,48 +142,6 @@
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const handleKeyboardPress = (event: KeyboardEvent) => {
 | 
			
		||||
    if (suggestedPeople.length === 0) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    if (!$showAssetViewer) {
 | 
			
		||||
      event.stopPropagation();
 | 
			
		||||
      switch (event.key) {
 | 
			
		||||
        case 'ArrowDown': {
 | 
			
		||||
          event.preventDefault();
 | 
			
		||||
          if (indexFocus === null) {
 | 
			
		||||
            indexFocus = 0;
 | 
			
		||||
          } else if (indexFocus === suggestedPeople.length - 1) {
 | 
			
		||||
            indexFocus = 0;
 | 
			
		||||
          } else {
 | 
			
		||||
            indexFocus++;
 | 
			
		||||
          }
 | 
			
		||||
          focusedElements[indexFocus]?.focus();
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
        case 'ArrowUp': {
 | 
			
		||||
          if (indexFocus === null) {
 | 
			
		||||
            indexFocus = 0;
 | 
			
		||||
            return;
 | 
			
		||||
          }
 | 
			
		||||
          if (indexFocus === 0) {
 | 
			
		||||
            indexFocus = suggestedPeople.length - 1;
 | 
			
		||||
          } else {
 | 
			
		||||
            indexFocus--;
 | 
			
		||||
          }
 | 
			
		||||
          focusedElements[indexFocus]?.focus();
 | 
			
		||||
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
        case 'Enter': {
 | 
			
		||||
          if (indexFocus !== null) {
 | 
			
		||||
            handleSuggestPeople(suggestedPeople[indexFocus]);
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleEscape = async () => {
 | 
			
		||||
    if ($showAssetViewer || viewMode === ViewMode.SUGGEST_MERGE) {
 | 
			
		||||
      return;
 | 
			
		||||
@ -401,7 +358,6 @@
 | 
			
		||||
  };
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<svelte:document on:keydown={handleKeyboardPress} />
 | 
			
		||||
{#if viewMode === ViewMode.UNASSIGN_ASSETS}
 | 
			
		||||
  <UnMergeFaceSelector
 | 
			
		||||
    assetIds={[...$selectedAssets].map((a) => a.id)}
 | 
			
		||||
@ -491,11 +447,12 @@
 | 
			
		||||
      {#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE}
 | 
			
		||||
        <!-- Person information block -->
 | 
			
		||||
        <div
 | 
			
		||||
          role="button"
 | 
			
		||||
          class="relative w-fit p-4 sm:px-6"
 | 
			
		||||
          use:clickOutside
 | 
			
		||||
          on:outclick={handleCancelEditName}
 | 
			
		||||
          on:escape={handleCancelEditName}
 | 
			
		||||
          use:clickOutside={{
 | 
			
		||||
            onOutclick: handleCancelEditName,
 | 
			
		||||
            onEscape: handleCancelEditName,
 | 
			
		||||
          }}
 | 
			
		||||
          use:listNavigation={suggestionContainer}
 | 
			
		||||
        >
 | 
			
		||||
          <section class="flex w-64 sm:w-96 place-items-center border-black">
 | 
			
		||||
            {#if isEditingName}
 | 
			
		||||
@ -550,9 +507,9 @@
 | 
			
		||||
                  </div>
 | 
			
		||||
                </div>
 | 
			
		||||
              {:else}
 | 
			
		||||
                <div bind:this={suggestionContainer}>
 | 
			
		||||
                  {#each suggestedPeople as person, index (person.id)}
 | 
			
		||||
                    <button
 | 
			
		||||
                    bind:this={focusedElements[index]}
 | 
			
		||||
                      class="flex w-full border-t border-gray-400 dark:border-immich-dark-gray h-14 place-items-center bg-gray-200 p-2 dark:bg-gray-700 hover:bg-gray-300 hover:dark:bg-[#232932] focus:bg-gray-300 focus:dark:bg-[#232932] {index ===
 | 
			
		||||
                      suggestedPeople.length - 1
 | 
			
		||||
                        ? 'rounded-b-lg border-b'
 | 
			
		||||
@ -570,6 +527,7 @@
 | 
			
		||||
                      <p class="ml-4 text-gray-700 dark:text-gray-100">{person.name}</p>
 | 
			
		||||
                    </button>
 | 
			
		||||
                  {/each}
 | 
			
		||||
                </div>
 | 
			
		||||
              {/if}
 | 
			
		||||
            </div>
 | 
			
		||||
          {/if}
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user