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

@ -6,13 +6,22 @@
import Combobox, { type ComboBoxOption } from './combobox.svelte';
interface Props {
title?: string;
initialDate?: DateTime;
initialTimeZone?: string;
timezoneInput?: boolean;
onCancel: () => void;
onConfirm: (date: string) => void;
}
let { initialDate = DateTime.now(), initialTimeZone = '', onCancel, onConfirm }: Props = $props();
let {
initialDate = DateTime.now(),
initialTimeZone = '',
title = $t('edit_date_and_time'),
timezoneInput = true,
onCancel,
onConfirm,
}: Props = $props();
type ZoneOption = {
/**
@ -135,7 +144,7 @@
<ConfirmModal
confirmColor="primary"
title={$t('edit_date_and_time')}
{title}
prompt="Please select a new date:"
disabled={!date.isValid}
onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
@ -148,15 +157,17 @@
<label for="datetime">{$t('date_and_time')}</label>
<DateInput class="immich-form-input" id="datetime" type="datetime-local" bind:value={selectedDate} />
</div>
<div>
<Combobox
bind:selectedOption
label={$t('timezone')}
options={timezones}
placeholder={$t('search_timezone')}
onSelect={(option) => handleOnSelect(option)}
/>
</div>
{#if timezoneInput}
<div>
<Combobox
bind:selectedOption
label={$t('timezone')}
options={timezones}
placeholder={$t('search_timezone')}
onSelect={(option) => handleOnSelect(option)}
/>
</div>
{/if}
</div>
{/snippet}
</ConfirmModal>

View file

@ -14,7 +14,7 @@
import { handlePromiseError } from '$lib/utils';
import { deleteAssets } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils';
import { focusNext } from '$lib/utils/focus-util';
import { moveFocus } from '$lib/utils/focus-util';
import { handleError } from '$lib/utils/handle-error';
import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
import { navigate } from '$lib/utils/navigation';
@ -271,8 +271,9 @@
}
};
const focusNextAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, true);
const focusPreviousAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, false);
const focusNextAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'next');
const focusPreviousAsset = () =>
moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'previous');
let isShortcutModalOpen = false;

View file

@ -3,10 +3,9 @@
import type { AssetStore, LiteBucket } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { getTabbable } from '$lib/utils/focus-util';
import { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util';
import { type ScrubberListener } from '$lib/utils/timeline-util';
import { mdiPlay } from '@mdi/js';
import { clamp } from 'lodash-es';
import { DateTime } from 'luxon';
import { onMount } from 'svelte';
import { fade, fly } from 'svelte/transition';
@ -17,7 +16,7 @@
assetStore: AssetStore;
scrubOverallPercent?: number;
scrubBucketPercent?: number;
scrubBucket?: { bucketDate: string | undefined };
scrubBucket?: { year: number; month: number };
leadout?: boolean;
scrubberWidth?: number;
onScrub?: ScrubberListener;
@ -81,7 +80,7 @@
});
const toScrollFromBucketPercentage = (
scrubBucket: { bucketDate: string | undefined } | undefined,
scrubBucket: { year: number; month: number } | undefined,
scrubBucketPercent: number,
scrubOverallPercent: number,
) => {
@ -89,7 +88,7 @@
let offset = relativeTopOffset;
let match = false;
for (const segment of segments) {
if (segment.bucketDate === scrubBucket.bucketDate) {
if (segment.month === scrubBucket.month && segment.year === scrubBucket.year) {
offset += scrubBucketPercent * segment.height;
match = true;
break;
@ -120,8 +119,8 @@
count: number;
height: number;
dateFormatted: string;
bucketDate: string;
date: DateTime;
year: number;
month: number;
hasLabel: boolean;
hasDot: boolean;
};
@ -141,9 +140,9 @@
top,
count: bucket.assetCount,
height: toScrollY(scrollBarPercentage),
bucketDate: bucket.bucketDate,
date: fromLocalDateTime(bucket.bucketDate),
dateFormatted: bucket.bucketDateFormattted,
year: bucket.year,
month: bucket.month,
hasLabel: false,
hasDot: false,
};
@ -153,7 +152,7 @@
segment.hasLabel = true;
previousLabeledSegment = segment;
} else {
if (previousLabeledSegment?.date?.year !== segment.date.year && height > MIN_YEAR_LABEL_DISTANCE) {
if (previousLabeledSegment?.year !== segment.year && height > MIN_YEAR_LABEL_DISTANCE) {
height = 0;
segment.hasLabel = true;
previousLabeledSegment = segment;
@ -182,7 +181,13 @@
}
return activeSegment?.dataset.label;
});
const bucketDate = $derived(activeSegment?.dataset.timeSegmentBucketDate);
const bucketDate = $derived.by(() => {
if (!activeSegment?.dataset.timeSegmentBucketDate) {
return undefined;
}
const [year, month] = activeSegment.dataset.timeSegmentBucketDate.split('-').map(Number);
return { year, month };
});
const scrollSegment = $derived.by(() => {
const y = scrollY;
let cur = relativeTopOffset;
@ -289,12 +294,12 @@
const scrollPercent = toTimelineY(hoverY);
if (wasDragging === false && isDragging) {
void startScrub?.(bucketDate, scrollPercent, bucketPercentY);
void onScrub?.(bucketDate, scrollPercent, bucketPercentY);
void startScrub?.(bucketDate!, scrollPercent, bucketPercentY);
void onScrub?.(bucketDate!, scrollPercent, bucketPercentY);
}
if (wasDragging && !isDragging) {
void stopScrub?.(bucketDate, scrollPercent, bucketPercentY);
void stopScrub?.(bucketDate!, scrollPercent, bucketPercentY);
return;
}
@ -302,7 +307,7 @@
return;
}
void onScrub?.(bucketDate, scrollPercent, bucketPercentY);
void onScrub?.(bucketDate!, scrollPercent, bucketPercentY);
};
const getTouch = (event: TouchEvent) => {
if (event.touches.length === 1) {
@ -404,7 +409,7 @@
}
if (next) {
event.preventDefault();
void onScrub?.(next.bucketDate, -1, 0);
void onScrub?.({ year: next.year, month: next.month }, -1, 0);
return true;
}
}
@ -414,7 +419,7 @@
const next = segments[idx + 1];
if (next) {
event.preventDefault();
void onScrub?.(next.bucketDate, -1, 0);
void onScrub?.({ year: next.year, month: next.month }, -1, 0);
return true;
}
}
@ -517,7 +522,7 @@
class="relative"
style:height={relativeTopOffset + 'px'}
data-id="lead-in"
data-time-segment-bucket-date={segments.at(0)?.date}
data-time-segment-bucket-date={segments.at(0)?.year + '-' + segments.at(0)?.month}
data-label={segments.at(0)?.dateFormatted}
>
{#if relativeTopOffset > 6}
@ -525,18 +530,18 @@
{/if}
</div>
<!-- Time Segment -->
{#each segments as segment (segment.date)}
{#each segments as segment (segment.year + '-' + segment.month)}
<div
class="relative"
data-id="time-segment"
data-time-segment-bucket-date={segment.date}
data-time-segment-bucket-date={segment.year + '-' + segment.month}
data-label={segment.dateFormatted}
style:height={segment.height + 'px'}
>
{#if !usingMobileDevice}
{#if segment.hasLabel}
<div class="absolute end-5 top-[-16px] text-[12px] dark:text-immich-dark-fg font-immich-mono">
{segment.date.year}
{segment.year}
</div>
{/if}
{#if segment.hasDot}