fix: responsive: timeline glitch and keyboard-accessible scrubber (#17556)

* fix: responsive: timeline glitch

* lint

* fix margin-right on mobile

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Min Idzelis 2025-04-14 12:56:40 -04:00 committed by GitHub
parent 664c99278a
commit 5a51ad3622
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 427 additions and 142 deletions

View file

@ -25,6 +25,7 @@
import ImageThumbnail from './image-thumbnail.svelte';
import VideoThumbnail from './video-thumbnail.svelte';
import { onMount } from 'svelte';
import { getFocusable } from '$lib/utils/focus-util';
interface Props {
asset: AssetResponseDto;
@ -222,10 +223,30 @@
if (evt.key === 'x') {
onSelect?.(asset);
}
if (document.activeElement === focussableElement && evt.key === 'Escape') {
const focusable = getFocusable(document);
const index = focusable.indexOf(focussableElement);
let i = index + 1;
while (i !== index) {
const next = focusable[i];
if (next.dataset.thumbnailFocusContainer !== undefined) {
if (i === focusable.length - 1) {
i = 0;
} else {
i++;
}
continue;
}
next.focus();
break;
}
}
}}
onclick={handleClick}
bind:this={focussableElement}
onfocus={handleFocus}
data-thumbnail-focus-container
data-testid="container-with-tabindex"
tabindex={0}
role="link"

View file

@ -78,13 +78,19 @@
let scrubBucketPercent = $state(0);
let scrubBucket: { bucketDate: string | undefined } | undefined = $state();
let scrubOverallPercent: number = $state(0);
let scrubberWidth = $state(0);
// 60 is the bottom spacer element at 60px
let bottomSectionHeight = 60;
let leadout = $state(false);
const maxMd = $derived(mobileDevice.maxMd);
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
$effect(() => {
assetStore.rowHeight = maxMd ? 100 : 235;
});
const scrollTo = (top: number) => {
element?.scrollTo({ top });
showSkeleton = false;
@ -162,7 +168,13 @@
const updateIsScrolling = () => (assetStore.scrolling = true);
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
const updateSlidingWindow = () => assetStore.updateSlidingWindow(element?.scrollTop || 0);
const compensateScrollCallback = (delta: number) => element?.scrollBy(0, delta);
const compensateScrollCallback = ({ delta, top }: { delta?: number; top?: number }) => {
if (delta) {
element?.scrollBy(0, delta);
} else if (top) {
element?.scrollTo({ top });
}
};
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (assetStore.topSectionHeight = height);
onMount(() => {
@ -267,10 +279,21 @@
bucket = assetStore.buckets[i];
bucketHeight = assetStore.buckets[i].bucketHeight;
}
let next = top - bucketHeight * maxScrollPercent;
if (next < 0) {
// instead of checking for < 0, add a little wiggle room for subpixel resolution
if (next < -1 && bucket) {
scrubBucket = bucket;
scrubBucketPercent = top / (bucketHeight * maxScrollPercent);
// allowing next to be at least 1 may cause percent to go negative, so ensure positive percentage
scrubBucketPercent = Math.max(0, top / (bucketHeight * maxScrollPercent));
// compensate for lost precision/rouding errors advance to the next bucket, if present
if (scrubBucketPercent > 0.9999 && i + 1 < bucketsLength - 1) {
scrubBucket = assetStore.buckets[i + 1];
scrubBucketPercent = 0;
}
found = true;
break;
}
@ -689,7 +712,6 @@
{#if assetStore.buckets.length > 0}
<Scrubber
invisible={showSkeleton}
{assetStore}
height={assetStore.viewportHeight}
timelineTopOffset={assetStore.topSectionHeight}
@ -699,6 +721,7 @@
{scrubBucketPercent}
{scrubBucket}
{onScrub}
bind:scrubberWidth
onScrubKeyDown={(evt) => {
evt.preventDefault();
let amount = 50;
@ -720,12 +743,8 @@
<!-- Right margin MUST be equal to the width of immich-scrubbable-scrollbar -->
<section
id="asset-grid"
class={[
'scrollbar-hidden h-full overflow-y-auto outline-none',
{ 'm-0': isEmpty },
{ 'ml-0': !isEmpty },
{ 'mr-[60px]': !isEmpty && !usingMobileDevice },
]}
class={['scrollbar-hidden h-full overflow-y-auto outline-none', { 'm-0': isEmpty }, { 'ml-0': !isEmpty }]}
style:margin-right={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
tabindex="-1"
bind:clientHeight={assetStore.viewportHeight}
bind:clientWidth={null, (v) => ((assetStore.viewportWidth = v), updateSlidingWindow())}
@ -763,7 +782,7 @@
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:width="100%"
>
<Skeleton height={bucket.bucketHeight} title={bucket.bucketDateFormatted} />
<Skeleton height={bucket.bucketHeight - bucket.store.headerHeight} title={bucket.bucketDateFormatted} />
</div>
{:else if display}
<div
@ -788,7 +807,14 @@
</div>
{/if}
{/each}
<!-- <div class="h-[60px]" style:position="absolute" style:left="0" style:right="0" style:bottom="0"></div> -->
<!-- spacer for leadout -->
<div
class="h-[60px]"
style:position="absolute"
style:left="0"
style:right="0"
style:transform={`translate3d(0,${assetStore.timelineHeight}px,0)`}
></div>
</section>
</section>

View file

@ -13,7 +13,11 @@
>
{title}
</div>
<div class="animate-pulse absolute h-full ml-[10px]" style:width="calc(100% - 10px)" data-skeleton="true"></div>
<div
class="animate-pulse absolute h-full ml-[10px] mr-[10px]"
style:width="calc(100% - 20px)"
data-skeleton="true"
></div>
</div>
<style>

View file

@ -2,6 +2,7 @@
import Icon from '$lib/components/elements/icon.svelte';
import type { AssetStore, LiteBucket } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { getFocusable } from '$lib/utils/focus-util';
import { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util';
import { mdiPlay } from '@mdi/js';
import { clamp } from 'lodash-es';
@ -18,6 +19,7 @@
scrubBucketPercent?: number;
scrubBucket?: { bucketDate: string | undefined };
leadout?: boolean;
scrubberWidth?: number;
onScrub?: ScrubberListener;
onScrubKeyDown?: (event: KeyboardEvent, element: HTMLElement) => void;
startScrub?: ScrubberListener;
@ -37,22 +39,47 @@
onScrubKeyDown = undefined,
startScrub = undefined,
stopScrub = undefined,
scrubberWidth = $bindable(),
}: Props = $props();
let isHover = $state(false);
let isDragging = $state(false);
let isHoverOnPaddingTop = $state(false);
let isHoverOnPaddingBottom = $state(false);
let hoverY = $state(0);
let clientY = 0;
let windowHeight = $state(0);
let scrollBar: HTMLElement | undefined = $state();
const toScrollY = (percent: number) => percent * (height - HOVER_DATE_HEIGHT * 2);
const toTimelineY = (scrollY: number) => scrollY / (height - HOVER_DATE_HEIGHT * 2);
const toScrollY = (percent: number) => percent * (height - (PADDING_TOP + PADDING_BOTTOM));
const toTimelineY = (scrollY: number) => scrollY / (height - (PADDING_TOP + PADDING_BOTTOM));
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
const MOBILE_WIDTH = 20;
const DESKTOP_WIDTH = 60;
const HOVER_DATE_HEIGHT = 31.75;
const PADDING_TOP = $derived(usingMobileDevice ? 25 : HOVER_DATE_HEIGHT);
const PADDING_BOTTOM = $derived(usingMobileDevice ? 25 : 10);
const MIN_YEAR_LABEL_DISTANCE = 16;
const MIN_DOT_DISTANCE = 8;
const width = $derived.by(() => {
if (isDragging) {
return '100vw';
}
if (usingMobileDevice) {
if (assetStore.scrolling) {
return MOBILE_WIDTH + 'px';
}
return '0px';
}
return DESKTOP_WIDTH + 'px';
});
$effect(() => {
scrubberWidth = usingMobileDevice ? MOBILE_WIDTH : DESKTOP_WIDTH;
});
const toScrollFromBucketPercentage = (
scrubBucket: { bucketDate: string | undefined } | undefined,
scrubBucketPercent: number,
@ -72,18 +99,16 @@
if (!match) {
offset += scrubBucketPercent * relativeBottomOffset;
}
// 2px is the height of the indicator
return offset - 2;
return offset;
} else if (leadout) {
let offset = relativeTopOffset;
for (const segment of segments) {
offset += segment.height;
}
offset += scrubOverallPercent * relativeBottomOffset;
return offset - 2;
return offset;
} else {
// 2px is the height of the indicator
return scrubOverallPercent * (height - HOVER_DATE_HEIGHT * 2) - 2;
return scrubOverallPercent * (height - (PADDING_TOP + PADDING_BOTTOM));
}
};
let scrollY = $derived(toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent));
@ -108,10 +133,12 @@
let segments: Segment[] = [];
let previousLabeledSegment: Segment | undefined;
let top = 0;
for (const [i, bucket] of buckets.entries()) {
const scrollBarPercentage = bucket.bucketHeight / timelineFullHeight;
const segment = {
top,
count: bucket.assetCount,
height: toScrollY(scrollBarPercentage),
bucketDate: bucket.bucketDate,
@ -120,7 +147,7 @@
hasLabel: false,
hasDot: false,
};
top += segment.height;
if (i === 0) {
segment.hasDot = true;
segment.hasLabel = true;
@ -146,19 +173,99 @@
};
let activeSegment: HTMLElement | undefined = $state();
const segments = $derived(calculateSegments(assetStore.scrubberBuckets));
const hoverLabel = $derived(activeSegment?.dataset.label);
const hoverLabel = $derived.by(() => {
if (isHoverOnPaddingTop) {
return segments.at(0)?.dateFormatted;
}
if (isHoverOnPaddingBottom) {
return segments.at(-1)?.dateFormatted;
}
return activeSegment?.dataset.label;
});
const bucketDate = $derived(activeSegment?.dataset.timeSegmentBucketDate);
const scrollHoverLabel = $derived.by(() => {
const scrollSegment = $derived.by(() => {
const y = scrollY;
let cur = 0;
let cur = relativeTopOffset;
for (const segment of segments) {
if (y <= cur + segment.height + relativeTopOffset) {
return segment.dateFormatted;
if (y < cur + segment.height) {
return segment;
}
cur += segment.height;
}
return '';
return null;
});
const scrollHoverLabel = $derived(scrollSegment?.dateFormatted || '');
const findElementBestY = (elements: Element[], y: number, ...ids: string[]) => {
if (ids.length === 0) {
return undefined;
}
const filtered = elements.filter((element) => {
if (element instanceof HTMLElement && element.dataset.id) {
return ids.includes(element.dataset.id);
}
return false;
}) as HTMLElement[];
const imperfect = [];
for (const element of filtered) {
const boundingClientRect = element.getBoundingClientRect();
if (boundingClientRect.y > y) {
imperfect.push({
element,
boundingClientRect,
});
continue;
}
if (y <= boundingClientRect.y + boundingClientRect.height) {
return {
element,
boundingClientRect,
};
}
}
return imperfect.at(0);
};
const getActive = (x: number, y: number) => {
const elements = document.elementsFromPoint(x, y);
const bestElement = findElementBestY(elements, y, 'time-segment', 'lead-in', 'lead-out');
if (bestElement) {
const segment = bestElement.element;
const boundingClientRect = bestElement.boundingClientRect;
const sy = boundingClientRect.y;
const relativeY = y - sy;
const bucketPercentY = relativeY / boundingClientRect.height;
return {
isOnPaddingTop: false,
isOnPaddingBottom: false,
segment,
bucketPercentY,
};
}
// check if padding
const bar = findElementBestY(elements, 0, 'immich-scrubbable-scrollbar');
let isOnPaddingTop = false;
let isOnPaddingBottom = false;
if (bar) {
const sr = bar.boundingClientRect;
if (y < sr.top + PADDING_TOP) {
isOnPaddingTop = true;
}
if (y > sr.bottom - PADDING_BOTTOM - 1) {
isOnPaddingBottom = true;
}
}
return {
isOnPaddingTop,
isOnPaddingBottom,
segment: undefined,
bucketPercentY: 0,
};
};
const handleMouseEvent = (event: { clientY: number; isDragging?: boolean }) => {
const wasDragging = isDragging;
@ -172,28 +279,13 @@
const rect = scrollBar.getBoundingClientRect()!;
const lower = 0;
const upper = rect?.height - HOVER_DATE_HEIGHT * 2;
hoverY = clamp(clientY - rect?.top - HOVER_DATE_HEIGHT, lower, upper);
const upper = rect?.height - (PADDING_TOP + PADDING_BOTTOM);
hoverY = clamp(clientY - rect?.top - PADDING_TOP, lower, upper);
const x = rect!.left + rect!.width / 2;
const elems = document.elementsFromPoint(x, clientY);
const segment = elems.find(({ id }) => id === 'time-segment');
let bucketPercentY = 0;
if (segment) {
activeSegment = segment as HTMLElement;
const sr = segment.getBoundingClientRect();
const sy = sr.y;
const relativeY = clientY - sy;
bucketPercentY = relativeY / sr.height;
} else {
const leadin = elems.find(({ id }) => id === 'lead-in');
if (leadin) {
activeSegment = leadin as HTMLElement;
} else {
activeSegment = undefined;
bucketPercentY = 0;
}
}
const { segment, bucketPercentY, isOnPaddingTop, isOnPaddingBottom } = getActive(x, clientY);
activeSegment = segment;
isHoverOnPaddingTop = isOnPaddingTop;
isHoverOnPaddingBottom = isOnPaddingBottom;
const scrollPercent = toTimelineY(hoverY);
if (wasDragging === false && isDragging) {
@ -225,9 +317,8 @@
return;
}
const elements = document.elementsFromPoint(touch.clientX, touch.clientY);
const isHoverScrollbar = elements.some(({ id }) => {
return id === 'immich-scrubbable-scrollbar' || id === 'time-label';
});
const isHoverScrollbar =
findElementBestY(elements, 0, 'immich-scrubbable-scrollbar', 'time-label', 'lead-in', 'lead-out') !== undefined;
isHover = isHoverScrollbar;
@ -253,21 +344,89 @@
handleMouseEvent({
clientY: touch.clientY,
});
event.preventDefault();
} else {
isHover = false;
}
};
onMount(() => {
const opts = {
passive: false,
};
globalThis.addEventListener('touchmove', onTouchMove, opts);
document.addEventListener('touchmove', onTouchMove, true);
return () => {
globalThis.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchmove', onTouchMove);
};
});
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
onMount(() => {
document.addEventListener('touchstart', onTouchStart, true);
document.addEventListener('touchend', onTouchEnd, true);
return () => {
document.addEventListener('touchstart', onTouchStart, true);
document.addEventListener('touchend', onTouchEnd, true);
};
});
const isTabEvent = (event: KeyboardEvent) => event?.key === 'Tab';
const isTabForward = (event: KeyboardEvent) => isTabEvent(event) && !event.shiftKey;
const isTabBackward = (event: KeyboardEvent) => isTabEvent(event) && event.shiftKey;
const isArrowUp = (event: KeyboardEvent) => event?.key === 'ArrowUp';
const isArrowDown = (event: KeyboardEvent) => event?.key === 'ArrowDown';
const handleFocus = (event: KeyboardEvent) => {
const forward = isTabForward(event);
const backward = isTabBackward(event);
if (forward || backward) {
event.preventDefault();
const focusable = getFocusable(document);
if (scrollBar) {
const index = focusable.indexOf(scrollBar);
if (index !== -1) {
let next: HTMLElement;
next = forward
? (focusable[(index + 1) % focusable.length] as HTMLElement)
: (focusable[(index - 1) % focusable.length] as HTMLElement);
next.focus();
}
}
}
};
const handleAccessibility = (event: KeyboardEvent) => {
if (isTabEvent(event)) {
handleFocus(event);
return true;
}
if (isArrowUp(event)) {
let next;
if (scrollSegment) {
const idx = segments.indexOf(scrollSegment);
next = idx === -1 ? segments.at(-2) : segments[idx - 1];
} else {
next = segments.at(-2);
}
if (next) {
event.preventDefault();
void onScrub?.(next.bucketDate, -1, 0);
return true;
}
}
if (isArrowDown(event) && scrollSegment) {
const idx = segments.indexOf(scrollSegment);
if (idx !== -1) {
const next = segments[idx + 1];
if (next) {
event.preventDefault();
void onScrub?.(next.bucketDate, -1, 0);
return true;
}
}
}
return false;
};
const keydown = (event: KeyboardEvent) => {
let handled = handleAccessibility(event);
if (!handled) {
onScrubKeyDown?.(event, event.currentTarget as HTMLElement);
}
};
</script>
<svelte:window
@ -275,30 +434,28 @@
onmousemove={({ clientY }) => (isDragging || isHover) && handleMouseEvent({ clientY })}
onmousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })}
onmouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })}
ontouchstart={onTouchStart}
ontouchend={onTouchEnd}
ontouchcancel={onTouchEnd}
/>
<div
transition:fly={{ x: 50, duration: 250 }}
tabindex="-1"
tabindex="0"
role="scrollbar"
aria-controls="time-label"
aria-valuenow={scrollY + HOVER_DATE_HEIGHT}
aria-valuemax={toScrollY(100)}
aria-valuetext={hoverLabel}
aria-valuenow={scrollY + PADDING_TOP}
aria-valuemax={toScrollY(1)}
aria-valuemin={toScrollY(0)}
id="immich-scrubbable-scrollbar"
data-id="immich-scrubbable-scrollbar"
class="absolute right-0 z-[1] select-none bg-immich-bg hover:cursor-row-resize"
style:padding-top={HOVER_DATE_HEIGHT + 'px'}
style:padding-bottom={HOVER_DATE_HEIGHT + 'px'}
style:width={isDragging ? '100vw' : '60px'}
style:padding-top={PADDING_TOP + 'px'}
style:padding-bottom={PADDING_BOTTOM + 'px'}
style:width
style:height={height + 'px'}
style:background-color={isDragging ? 'transparent' : 'transparent'}
bind:this={scrollBar}
onmouseenter={() => (isHover = true)}
onmouseleave={() => (isHover = false)}
onkeydown={(event) => onScrubKeyDown?.(event, event.currentTarget)}
onkeydown={keydown}
draggable="false"
>
{#if !usingMobileDevice && hoverLabel && (isHover || isDragging)}
@ -318,7 +475,7 @@
<div
id="time-label"
class="rounded-l-full w-[32px] pl-2 text-white bg-immich-primary dark:bg-gray-600 hover:cursor-pointer select-none"
style:top="{scrollY + HOVER_DATE_HEIGHT - 25}px"
style:top="{PADDING_TOP + (scrollY - 50 / 2)}px"
style:height="50px"
style:right="0"
style:position="absolute"
@ -344,9 +501,9 @@
{#if !usingMobileDevice && !isDragging}
<div
class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary"
style:top="{scrollY + HOVER_DATE_HEIGHT}px"
style:top="{scrollY + PADDING_TOP - 2}px"
>
{#if assetStore.scrolling && scrollHoverLabel}
{#if assetStore.scrolling && scrollHoverLabel && !isHover}
<p
transition:fade={{ duration: 200 }}
class="truncate pointer-events-none absolute right-0 bottom-0 z-[100] min-w-20 max-w-64 w-fit rounded-tl-md border-b-2 border-immich-primary bg-immich-bg/80 py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:bg-immich-dark-gray/80 dark:text-immich-dark-fg"
@ -356,7 +513,13 @@
{/if}
</div>
{/if}
<div id="lead-in" class="relative" style:height={relativeTopOffset + 'px'} data-label={segments.at(0)?.dateFormatted}>
<div
class="relative z-10"
style:height={relativeTopOffset + 'px'}
data-id="lead-in"
data-time-segment-bucket-date={segments.at(0)?.date}
data-label={segments.at(0)?.dateFormatted}
>
{#if relativeTopOffset > 6}
<div class="absolute right-[0.75rem] h-[4px] w-[4px] rounded-full bg-gray-300"></div>
{/if}
@ -364,28 +527,26 @@
<!-- Time Segment -->
{#each segments as segment (segment.date)}
<div
id="time-segment"
class="relative"
data-id="time-segment"
data-time-segment-bucket-date={segment.date}
data-label={segment.dateFormatted}
style:height={segment.height + 'px'}
>
{#if !usingMobileDevice && segment.hasLabel}
<div class="absolute right-[1.25rem] top-[-16px] z-10 text-[12px] dark:text-immich-dark-fg font-immich-mono">
{segment.date.year}
</div>
{/if}
{#if !usingMobileDevice && segment.hasDot}
<div class="absolute right-[0.75rem] bottom-0 h-[4px] w-[4px] rounded-full bg-gray-300"></div>
{#if !usingMobileDevice}
{#if segment.hasLabel}
<div class="absolute right-[1.25rem] top-[-16px] z-10 text-[12px] dark:text-immich-dark-fg font-immich-mono">
{segment.date.year}
</div>
{/if}
{#if segment.hasDot}
<div class="absolute right-[0.75rem] bottom-0 h-[4px] w-[4px] rounded-full bg-gray-300"></div>
{/if}
{/if}
</div>
{/each}
<div id="lead-out" class="relative" style:height={relativeBottomOffset + 'px'}></div>
<div data-id="lead-out" class="relative" style:height={relativeBottomOffset + 'px'}></div>
</div>
<style>
#immich-scrubbable-scrollbar,
#time-segment {
contain: layout size style;
}
</style>