Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(web): list navigation with keyboard #7987

Merged
merged 1 commit into from
Mar 15, 2024
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
60 changes: 9 additions & 51 deletions web/src/lib/components/shared-components/change-location.svelte
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand All @@ -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;

Expand All @@ -41,7 +41,6 @@
$: {
if (places) {
suggestedPlaces = places.slice(0, 5);
indexFocus = null;
}
if (searchWord === '') {
suggestedPlaces = [];
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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'
Expand Down
32 changes: 32 additions & 0 deletions web/src/lib/utils/list-navigation.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
11 changes: 7 additions & 4 deletions web/src/lib/utils/shortcut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type Shortcut = {

export type ShortcutOptions<T = HTMLElement> = {
shortcut: Shortcut;
ignoreInputFields?: boolean;
onShortcut: (event: KeyboardEvent & { currentTarget: T }) => unknown;
};

Expand Down Expand Up @@ -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 });
Expand Down
98 changes: 28 additions & 70 deletions web/src/routes/(user)/people/[personId]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 === '') {
Expand All @@ -122,7 +122,6 @@
$: {
if (people) {
suggestedPeople = name ? searchNameLocal(name, people, 5, data.person.id) : [];
indexFocus = null;
}
}

Expand All @@ -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;
Expand Down Expand Up @@ -401,7 +358,6 @@
};
</script>

<svelte:document on:keydown={handleKeyboardPress} />
{#if viewMode === ViewMode.UNASSIGN_ASSETS}
<UnMergeFaceSelector
assetIds={[...$selectedAssets].map((a) => a.id)}
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -550,26 +507,27 @@
</div>
</div>
{:else}
{#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'
: ''}"
on:click={() => handleSuggestPeople(person)}
>
<ImageThumbnail
circle
shadow
url={getPeopleThumbnailUrl(person.id)}
altText={person.name}
widthStyle="2rem"
heightStyle="2rem"
/>
<p class="ml-4 text-gray-700 dark:text-gray-100">{person.name}</p>
</button>
{/each}
<div bind:this={suggestionContainer}>
{#each suggestedPeople as person, index (person.id)}
<button
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'
: ''}"
on:click={() => handleSuggestPeople(person)}
>
<ImageThumbnail
circle
shadow
url={getPeopleThumbnailUrl(person.id)}
altText={person.name}
widthStyle="2rem"
heightStyle="2rem"
/>
<p class="ml-4 text-gray-700 dark:text-gray-100">{person.name}</p>
</button>
{/each}
</div>
{/if}
</div>
{/if}
Expand Down