feat: keyboard navigation to timeline (#17798)

* feat: improve focus

* feat: keyboard nav

* feat: improve focus

* typo

* test

* fix test

* lint

* bad merge

* lint

* inadvertent

* lint

* fix: flappy e2e test

* bad merge and fix tests

* use modulus in loop

* tests

* react to modal dialog refactor

* regression due to deferLayout

* Review comments

* Re-use change-date instead of new component

* bad merge

* Review comments

* rework moveFocus

* lint

* Fix outline

* use Date

* Finish up removing/reducing date parsing

* lint

* title

* strings

* Rework dates, rework earlier/later algorithm

* bad merge

* fix tests

* Fix race in scroll comp

* consolidate scroll methods

* Review comments

* console.log

* Edge cases in scroll compensation

* edge case, optimizations

* review comments

* lint

* lint

* More edge cases

* lint

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Min Idzelis 2025-05-28 09:55:14 -04:00 committed by GitHub
parent b5593823a2
commit f029910dc7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1077 additions and 598 deletions

View file

@ -20,7 +20,7 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { focusNext } from '$lib/utils/focus-util';
import { moveFocus } from '$lib/utils/focus-util';
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
import { TUNABLES } from '$lib/utils/tunables';
import { onMount } from 'svelte';
@ -48,7 +48,6 @@
onClick?: (asset: TimelineAsset) => void;
onSelect?: (asset: TimelineAsset) => void;
onMouseEvent?: (event: { isMouseOver: boolean; selectedGroupIndex: number }) => void;
handleFocus?: () => void;
}
let {
@ -67,7 +66,6 @@
onClick = undefined,
onSelect = undefined,
onMouseEvent = undefined,
handleFocus = undefined,
imageClass = '',
brokenAssetClass = '',
dimmed = false,
@ -140,12 +138,14 @@
let startX: number = 0;
let startY: number = 0;
function longPress(element: HTMLElement, { onLongPress }: { onLongPress: () => void }) {
let didPress = false;
const start = (evt: PointerEvent) => {
startX = evt.clientX;
startY = evt.clientY;
didPress = false;
// 350ms for longpress. For reference: iOS uses 500ms for default long press, or 200ms for fast long press.
timer = setTimeout(() => {
onLongPress();
element.addEventListener('contextmenu', preventContextMenu, { once: true });
@ -193,14 +193,41 @@
</script>
<div
data-asset={asset.id}
class={[
'focus-visible:outline-none flex overflow-hidden',
disabled ? 'bg-gray-300' : 'bg-immich-primary/20 dark:bg-immich-dark-primary/20',
]}
style:width="{width}px"
style:height="{height}px"
onmouseenter={onMouseEnter}
onmouseleave={onMouseLeave}
use:longPress={{ onLongPress: () => onSelect?.($state.snapshot(asset)) }}
onkeydown={(evt) => {
if (evt.key === 'Enter') {
callClickHandlers();
}
if (evt.key === 'x') {
onSelect?.(asset);
}
if (document.activeElement === element && evt.key === 'Escape') {
moveFocus((element) => element.dataset.thumbnailFocusContainer === undefined, 'next');
}
}}
onclick={handleClick}
bind:this={element}
data-asset={asset.id}
data-thumbnail-focus-container
tabindex={0}
role="link"
>
<!-- Outline on focus -->
<div
class={[
'pointer-events-none absolute z-1 size-full outline-hidden outline-4 -outline-offset-4 outline-immich-primary',
{ 'rounded-xl': selected },
]}
data-outline
></div>
{#if (!loaded || thumbError) && asset.thumbhash}
<canvas
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
@ -211,36 +238,10 @@
></canvas>
{/if}
<!-- as of iOS17, there is a preference for long press speed, which is not available for mobile web.
The defaults are as follows:
fast: 200ms
default: 500ms
slow: ??ms
-->
<div
class={['group absolute -top-[0px] -bottom-[0px]', { 'cursor-not-allowed': disabled, 'cursor-pointer': !disabled }]}
style:width="inherit"
style:height="inherit"
onmouseenter={onMouseEnter}
onmouseleave={onMouseLeave}
use:longPress={{ onLongPress: () => onSelect?.($state.snapshot(asset)) }}
onkeydown={(evt) => {
if (evt.key === 'Enter') {
callClickHandlers();
}
if (evt.key === 'x') {
onSelect?.(asset);
}
if (document.activeElement === element && evt.key === 'Escape') {
focusNext((element) => element.dataset.thumbnailFocusContainer === undefined, true);
}
}}
onclick={handleClick}
bind:this={element}
onfocus={handleFocus}
data-thumbnail-focus-container
tabindex={0}
role="link"
>
<div
class={[
@ -265,13 +266,6 @@
{#if dimmed && !mouseOver}
<div id="a" class={['absolute h-full w-full bg-gray-700/40', { 'rounded-xl': selected }]}></div>
{/if}
<!-- Outline on focus -->
<div
class={[
'absolute size-full group-focus-visible:outline-immich-primary focus:outline-4 -outline-offset-4 outline-immich-primary',
{ 'rounded-xl': selected },
]}
></div>
<!-- Favorite asset star -->
{#if !authManager.key && asset.isFavorite}
@ -372,7 +366,6 @@
class={['absolute p-2 focus:outline-none', { 'cursor-not-allowed': disabled }]}
role="checkbox"
tabindex={-1}
onfocus={handleFocus}
aria-checked={selected}
{disabled}
>
@ -389,3 +382,9 @@
{/if}
</div>
</div>
<style>
[data-asset]:focus > [data-outline] {
outline-style: solid;
}
</style>