Keyboard Accessible Scrubber

This commit is contained in:
Min Idzelis 2025-04-11 21:19:01 +00:00
parent e6cffac6cc
commit 8706f7cfe3
11 changed files with 226 additions and 70 deletions

View File

@ -1,4 +1,5 @@
import { shortcuts } from '$lib/actions/shortcut'; import { shortcuts } from '$lib/actions/shortcut';
import { getFocusable } from '$lib/utils/focus-util';
import { tick } from 'svelte'; import { tick } from 'svelte';
interface Options { interface Options {
@ -8,9 +9,6 @@ interface Options {
active?: boolean; active?: boolean;
} }
const selectors =
'button:not([disabled], .hidden), [href]:not(.hidden), input:not([disabled], .hidden), select:not([disabled], .hidden), textarea:not([disabled], .hidden), [tabindex]:not([tabindex="-1"], .hidden)';
export function focusTrap(container: HTMLElement, options?: Options) { export function focusTrap(container: HTMLElement, options?: Options) {
const triggerElement = document.activeElement; const triggerElement = document.activeElement;
@ -21,7 +19,7 @@ export function focusTrap(container: HTMLElement, options?: Options) {
}; };
const setInitialFocus = () => { const setInitialFocus = () => {
const focusableElement = container.querySelector<HTMLElement>(selectors); const focusableElement = getFocusable(container)[0];
// Use tick() to ensure focus trap works correctly inside <Portal /> // Use tick() to ensure focus trap works correctly inside <Portal />
void tick().then(() => focusableElement?.focus()); void tick().then(() => focusableElement?.focus());
}; };
@ -30,11 +28,11 @@ export function focusTrap(container: HTMLElement, options?: Options) {
setInitialFocus(); setInitialFocus();
} }
const getFocusableElements = (): [HTMLElement | null, HTMLElement | null] => { const getFocusableElements = () => {
const focusableElements = container.querySelectorAll<HTMLElement>(selectors); const focusableElements = getFocusable(container);
return [ return [
focusableElements.item(0), // focusableElements.at(0), //
focusableElements.item(focusableElements.length - 1), focusableElements.at(-1),
]; ];
}; };

View File

@ -595,7 +595,7 @@
id="stack-slideshow" id="stack-slideshow"
class="z-[1002] flex place-item-center place-content-center absolute bottom-0 w-full col-span-4 col-start-1 overflow-x-auto horizontal-scrollbar" class="z-[1002] flex place-item-center place-content-center absolute bottom-0 w-full col-span-4 col-start-1 overflow-x-auto horizontal-scrollbar"
> >
<div class="relative w-full whitespace-nowrap"> <div class="relative w-full">
{#each stackedAssets as stackedAsset (stackedAsset.id)} {#each stackedAssets as stackedAsset (stackedAsset.id)}
<div <div
class={['inline-block px-1 relative transition-all pb-2']} class={['inline-block px-1 relative transition-all pb-2']}
@ -603,6 +603,7 @@
> >
<Thumbnail <Thumbnail
imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }} imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }}
brokenAssetClass="text-xs"
dimmed={stackedAsset.id !== asset.id} dimmed={stackedAsset.id !== asset.id}
asset={stackedAsset} asset={stackedAsset}
onClick={(stackedAsset) => { onClick={(stackedAsset) => {

View File

@ -184,7 +184,9 @@
]} ]}
/> />
{#if imageError} {#if imageError}
<BrokenAsset class="text-xl" /> <div class="h-full w-full">
<BrokenAsset class="text-xl h-full w-full" />
</div>
{/if} {/if}
<!-- svelte-ignore a11y_missing_attribute --> <!-- svelte-ignore a11y_missing_attribute -->
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" /> <img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />

View File

@ -14,7 +14,7 @@
</script> </script>
<div <div
class="flex flex-col overflow-hidden max-h-full max-w-full justify-center items-center bg-gray-100 dark:bg-gray-700 dark:text-gray-100 p-4 {className}" class="flex flex-col overflow-hidden max-h-full max-w-full justify-center items-center bg-gray-100/40 dark:bg-gray-700/40 dark:text-gray-100 p-4 {className}"
style:width style:width
style:height style:height
> >

View File

@ -21,7 +21,8 @@
border?: boolean; border?: boolean;
hiddenIconClass?: string; hiddenIconClass?: string;
class?: ClassValue; class?: ClassValue;
onComplete?: (() => void) | undefined; brokenAssetClass?: ClassValue;
onComplete?: ((errored: boolean) => void) | undefined;
} }
let { let {
@ -39,6 +40,7 @@
hiddenIconClass = 'text-white', hiddenIconClass = 'text-white',
onComplete = undefined, onComplete = undefined,
class: imageClass = '', class: imageClass = '',
brokenAssetClass = '',
}: Props = $props(); }: Props = $props();
let { let {
@ -50,17 +52,17 @@
const setLoaded = () => { const setLoaded = () => {
loaded = true; loaded = true;
onComplete?.(); onComplete?.(false);
}; };
const setErrored = () => { const setErrored = () => {
errored = true; errored = true;
onComplete?.(); onComplete?.(true);
}; };
function mount(elem: HTMLImageElement) { function mount(elem: HTMLImageElement) {
if (elem.complete) { if (elem.complete) {
loaded = true; loaded = true;
onComplete?.(); onComplete?.(false);
} }
} }
@ -71,6 +73,7 @@
shadow && 'shadow-lg', shadow && 'shadow-lg',
(circle || !heightStyle) && 'aspect-square', (circle || !heightStyle) && 'aspect-square',
border && 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary', border && 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary',
brokenAssetClass,
] ]
.filter(Boolean) .filter(Boolean)
.join(' '), .join(' '),

View File

@ -25,6 +25,7 @@
import ImageThumbnail from './image-thumbnail.svelte'; import ImageThumbnail from './image-thumbnail.svelte';
import VideoThumbnail from './video-thumbnail.svelte'; import VideoThumbnail from './video-thumbnail.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { getFocusable } from '$lib/utils/focus-util';
interface Props { interface Props {
asset: AssetResponseDto; asset: AssetResponseDto;
@ -41,6 +42,7 @@
showArchiveIcon?: boolean; showArchiveIcon?: boolean;
showStackedIcon?: boolean; showStackedIcon?: boolean;
imageClass?: ClassValue; imageClass?: ClassValue;
brokenAssetClass?: ClassValue;
dimmed?: boolean; dimmed?: boolean;
onClick?: ((asset: AssetResponseDto) => void) | undefined; onClick?: ((asset: AssetResponseDto) => void) | undefined;
onSelect?: ((asset: AssetResponseDto) => void) | undefined; onSelect?: ((asset: AssetResponseDto) => void) | undefined;
@ -67,6 +69,7 @@
onMouseEvent = undefined, onMouseEvent = undefined,
handleFocus = undefined, handleFocus = undefined,
imageClass = '', imageClass = '',
brokenAssetClass = '',
dimmed = false, dimmed = false,
}: Props = $props(); }: Props = $props();
@ -78,6 +81,7 @@
let focussableElement: HTMLElement | undefined = $state(); let focussableElement: HTMLElement | undefined = $state();
let mouseOver = $state(false); let mouseOver = $state(false);
let loaded = $state(false); let loaded = $state(false);
let thumbError = $state(false);
$effect(() => { $effect(() => {
if (focussed && document.activeElement !== focussableElement) { if (focussed && document.activeElement !== focussableElement) {
@ -189,10 +193,10 @@
style:width="{width}px" style:width="{width}px"
style:height="{height}px" style:height="{height}px"
> >
{#if !loaded && asset.thumbhash} {#if (!loaded || thumbError) && asset.thumbhash}
<canvas <canvas
use:thumbhash={{ base64ThumbHash: asset.thumbhash }} use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
class="absolute object-cover z-10" class="absolute object-cover z-40"
style:width="{width}px" style:width="{width}px"
style:height="{height}px" style:height="{height}px"
out:fade={{ duration: THUMBHASH_FADE_DURATION }} out:fade={{ duration: THUMBHASH_FADE_DURATION }}
@ -219,10 +223,30 @@
if (evt.key === 'x') { if (evt.key === 'x') {
onSelect?.(asset); 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} onclick={handleClick}
bind:this={focussableElement} bind:this={focussableElement}
onfocus={handleFocus} onfocus={handleFocus}
data-thumbnail-focus-container
data-testid="container-with-tabindex" data-testid="container-with-tabindex"
tabindex={0} tabindex={0}
role="link" role="link"
@ -332,12 +356,13 @@
</div> </div>
<ImageThumbnail <ImageThumbnail
class={imageClass} class={imageClass}
{brokenAssetClass}
url={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })} url={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
altText={$getAltText(asset)} altText={$getAltText(asset)}
widthStyle="{width}px" widthStyle="{width}px"
heightStyle="{height}px" heightStyle="{height}px"
curve={selected} curve={selected}
onComplete={() => (loaded = true)} onComplete={(errored) => ((loaded = true), (thumbError = errored))}
/> />
{#if asset.type === AssetTypeEnum.Video} {#if asset.type === AssetTypeEnum.Video}
<div class="absolute top-0 h-full w-full"> <div class="absolute top-0 h-full w-full">

View File

@ -78,12 +78,18 @@
let scrubBucketPercent = $state(0); let scrubBucketPercent = $state(0);
let scrubBucket: { bucketDate: string | undefined } | undefined = $state(); let scrubBucket: { bucketDate: string | undefined } | undefined = $state();
let scrubOverallPercent: number = $state(0); let scrubOverallPercent: number = $state(0);
let scrubberWidth = $state(0);
// 60 is the bottom spacer element at 60px // 60 is the bottom spacer element at 60px
let bottomSectionHeight = 60; let bottomSectionHeight = 60;
let leadout = $state(false); let leadout = $state(false);
const usingMobileDevice = $derived(mobileDevice.pointerCoarse); const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
const maxMd = $derived(mobileDevice.maxMd);
$effect(() => {
assetStore.rowHeight = maxMd ? 100 : 235;
});
const scrollTo = (top: number) => { const scrollTo = (top: number) => {
element?.scrollTo({ top }); element?.scrollTo({ top });
@ -273,10 +279,21 @@
bucket = assetStore.buckets[i]; bucket = assetStore.buckets[i];
bucketHeight = assetStore.buckets[i].bucketHeight; bucketHeight = assetStore.buckets[i].bucketHeight;
} }
let next = top - bucketHeight * maxScrollPercent; let next = top - bucketHeight * maxScrollPercent;
if (next < 0 && bucket) { // instead of checking for < 0, add a little wiggle room for subpixel resolution
if (next < -1 && bucket) {
scrubBucket = 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; found = true;
break; break;
} }
@ -704,6 +721,7 @@
{scrubBucketPercent} {scrubBucketPercent}
{scrubBucket} {scrubBucket}
{onScrub} {onScrub}
bind:scrubberWidth
onScrubKeyDown={(evt) => { onScrubKeyDown={(evt) => {
evt.preventDefault(); evt.preventDefault();
let amount = 50; let amount = 50;
@ -725,15 +743,11 @@
<!-- Right margin MUST be equal to the width of immich-scrubbable-scrollbar --> <!-- Right margin MUST be equal to the width of immich-scrubbable-scrollbar -->
<section <section
id="asset-grid" id="asset-grid"
class={[ class={['scrollbar-hidden h-full overflow-y-auto outline-none', { 'm-0': isEmpty }, { 'ml-0': !isEmpty }]}
'scrollbar-hidden h-full overflow-y-auto outline-none', style:margin-right={scrubberWidth + 'px'}
{ 'm-0': isEmpty },
{ 'ml-0': !isEmpty },
{ 'mr-[60px]': !isEmpty && !usingMobileDevice },
]}
tabindex="-1" tabindex="-1"
bind:clientHeight={assetStore.viewportHeight} bind:clientHeight={assetStore.viewportHeight}
bind:clientWidth={null, (v) => ((assetStore.viewportWidth = v), updateSlidingWindow())} bind:clientWidth={null, (v) => ((assetStore.viewportWidth = v - scrubberWidth), updateSlidingWindow())}
bind:this={element} bind:this={element}
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())} onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
> >
@ -767,7 +781,6 @@
style:position="absolute" style:position="absolute"
style:transform={`translate3d(0,${absoluteHeight}px,0)`} style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:width="100%" style:width="100%"
style:padding-left="10px"
> >
<Skeleton height={bucket.bucketHeight - bucket.store.headerHeight} title={bucket.bucketDateFormatted} /> <Skeleton height={bucket.bucketHeight - bucket.store.headerHeight} title={bucket.bucketDateFormatted} />
</div> </div>
@ -794,6 +807,7 @@
</div> </div>
{/if} {/if}
{/each} {/each}
<!-- spacer for leadout -->
<div <div
class="h-[60px]" class="h-[60px]"
style:position="absolute" style:position="absolute"

View File

@ -13,7 +13,7 @@
> >
{title} {title}
</div> </div>
<div class="animate-pulse absolute w-full h-full" data-skeleton="true"></div> <div class="animate-pulse absolute h-full ml-[10px]" style:width="calc(100% - 10px)" data-skeleton="true"></div>
</div> </div>
<style> <style>
@ -22,7 +22,7 @@
background-repeat: repeat; background-repeat: repeat;
background-size: 235px, 235px; background-size: 235px, 235px;
} }
@media (max-width: 850px) { @media (max-width: 767px) {
[data-skeleton] { [data-skeleton] {
background-size: 100px, 100px; background-size: 100px, 100px;
} }

View File

@ -2,6 +2,7 @@
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import type { AssetStore, LiteBucket } from '$lib/stores/assets-store.svelte'; import type { AssetStore, LiteBucket } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.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 { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util';
import { mdiPlay } from '@mdi/js'; import { mdiPlay } from '@mdi/js';
import { clamp } from 'lodash-es'; import { clamp } from 'lodash-es';
@ -18,6 +19,7 @@
scrubBucketPercent?: number; scrubBucketPercent?: number;
scrubBucket?: { bucketDate: string | undefined }; scrubBucket?: { bucketDate: string | undefined };
leadout?: boolean; leadout?: boolean;
scrubberWidth?: number;
onScrub?: ScrubberListener; onScrub?: ScrubberListener;
onScrubKeyDown?: (event: KeyboardEvent, element: HTMLElement) => void; onScrubKeyDown?: (event: KeyboardEvent, element: HTMLElement) => void;
startScrub?: ScrubberListener; startScrub?: ScrubberListener;
@ -37,6 +39,7 @@
onScrubKeyDown = undefined, onScrubKeyDown = undefined,
startScrub = undefined, startScrub = undefined,
stopScrub = undefined, stopScrub = undefined,
scrubberWidth = $bindable(),
}: Props = $props(); }: Props = $props();
let isHover = $state(false); let isHover = $state(false);
@ -52,24 +55,30 @@
const toTimelineY = (scrollY: number) => scrollY / (height - (PADDING_TOP + PADDING_BOTTOM)); const toTimelineY = (scrollY: number) => scrollY / (height - (PADDING_TOP + PADDING_BOTTOM));
const usingMobileDevice = $derived(mobileDevice.pointerCoarse); 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(() => { const width = $derived.by(() => {
if (isDragging) { if (isDragging) {
return '100vw'; return '100vw';
} }
if (usingMobileDevice) { if (usingMobileDevice) {
if (assetStore.scrolling) { if (assetStore.scrolling) {
return '20px'; return MOBILE_WIDTH + 'px';
} }
return '0px'; return '0px';
} }
return '60px'; return DESKTOP_WIDTH + 'px';
});
$effect(() => {
scrubberWidth = usingMobileDevice ? MOBILE_WIDTH : DESKTOP_WIDTH;
}); });
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 toScrollFromBucketPercentage = ( const toScrollFromBucketPercentage = (
scrubBucket: { bucketDate: string | undefined } | undefined, scrubBucket: { bucketDate: string | undefined } | undefined,
@ -90,18 +99,16 @@
if (!match) { if (!match) {
offset += scrubBucketPercent * relativeBottomOffset; offset += scrubBucketPercent * relativeBottomOffset;
} }
// 2px is the height of the indicator return offset;
return offset - 2;
} else if (leadout) { } else if (leadout) {
let offset = relativeTopOffset; let offset = relativeTopOffset;
for (const segment of segments) { for (const segment of segments) {
offset += segment.height; offset += segment.height;
} }
offset += scrubOverallPercent * relativeBottomOffset; offset += scrubOverallPercent * relativeBottomOffset;
return offset - 2; return offset;
} else { } else {
// 2px is the height of the indicator return scrubOverallPercent * (height - (PADDING_TOP + PADDING_BOTTOM));
return scrubOverallPercent * (height - (PADDING_TOP + PADDING_BOTTOM)) - 2;
} }
}; };
let scrollY = $derived(toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent)); let scrollY = $derived(toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent));
@ -126,10 +133,12 @@
let segments: Segment[] = []; let segments: Segment[] = [];
let previousLabeledSegment: Segment | undefined; let previousLabeledSegment: Segment | undefined;
let top = 0;
for (const [i, bucket] of buckets.entries()) { for (const [i, bucket] of buckets.entries()) {
const scrollBarPercentage = bucket.bucketHeight / timelineFullHeight; const scrollBarPercentage = bucket.bucketHeight / timelineFullHeight;
const segment = { const segment = {
top,
count: bucket.assetCount, count: bucket.assetCount,
height: toScrollY(scrollBarPercentage), height: toScrollY(scrollBarPercentage),
bucketDate: bucket.bucketDate, bucketDate: bucket.bucketDate,
@ -138,7 +147,7 @@
hasLabel: false, hasLabel: false,
hasDot: false, hasDot: false,
}; };
top += segment.height;
if (i === 0) { if (i === 0) {
segment.hasDot = true; segment.hasDot = true;
segment.hasLabel = true; segment.hasLabel = true;
@ -174,41 +183,59 @@
return activeSegment?.dataset.label; return activeSegment?.dataset.label;
}); });
const bucketDate = $derived(activeSegment?.dataset.timeSegmentBucketDate); const bucketDate = $derived(activeSegment?.dataset.timeSegmentBucketDate);
const scrollHoverLabel = $derived.by(() => { const scrollSegment = $derived.by(() => {
const y = scrollY; const y = scrollY;
let cur = 0; let cur = relativeTopOffset;
for (const segment of segments) { for (const segment of segments) {
if (y <= cur + segment.height + relativeTopOffset) { if (y < cur + segment.height) {
return segment.dateFormatted; return segment;
} }
cur += segment.height; cur += segment.height;
} }
return ''; return null;
}); });
const scrollHoverLabel = $derived(scrollSegment?.dateFormatted || '');
const findElement = (elements: Element[], ...ids: string[]) => { const findElementBestY = (elements: Element[], y: number, ...ids: string[]) => {
if (ids.length === 0) { if (ids.length === 0) {
return undefined; return undefined;
} }
const result = elements.find((element) => { const filtered = elements.filter((element) => {
if (element instanceof HTMLElement && element.dataset.id) { if (element instanceof HTMLElement && element.dataset.id) {
return ids.includes(element.dataset.id); return ids.includes(element.dataset.id);
} }
return false; return false;
}); }) as HTMLElement[];
return result as HTMLElement | undefined; 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 getActive = (x: number, y: number) => {
const elements = document.elementsFromPoint(x, y); const elements = document.elementsFromPoint(x, y);
const element = findElement(elements, 'time-segment', 'lead-in', 'lead-out'); const bestElement = findElementBestY(elements, y, 'time-segment', 'lead-in', 'lead-out');
if (element) { if (bestElement) {
const segment = element as HTMLElement; const segment = bestElement.element;
const sr = segment.getBoundingClientRect(); const boundingClientRect = bestElement.boundingClientRect;
const sy = sr.y; const sy = boundingClientRect.y;
const relativeY = y - sy; const relativeY = y - sy;
const bucketPercentY = relativeY / sr.height; const bucketPercentY = relativeY / boundingClientRect.height;
return { return {
isOnPaddingTop: false, isOnPaddingTop: false,
isOnPaddingBottom: false, isOnPaddingBottom: false,
@ -218,14 +245,12 @@
} }
// check if padding // check if padding
const bar = findElement(elements, 'immich-scrubbable-scrollbar'); const bar = findElementBestY(elements, 0, 'immich-scrubbable-scrollbar');
let isOnPaddingTop = false; let isOnPaddingTop = false;
let isOnPaddingBottom = false; let isOnPaddingBottom = false;
if (bar) { if (bar) {
const segment = bar as HTMLElement; const sr = bar.boundingClientRect;
const sr = segment.getBoundingClientRect();
if (y < sr.top + PADDING_TOP) { if (y < sr.top + PADDING_TOP) {
isOnPaddingTop = true; isOnPaddingTop = true;
} }
@ -293,7 +318,7 @@
} }
const elements = document.elementsFromPoint(touch.clientX, touch.clientY); const elements = document.elementsFromPoint(touch.clientX, touch.clientY);
const isHoverScrollbar = const isHoverScrollbar =
findElement(elements, 'immich-scrubbable-scrollbar', 'time-label', 'lead-in', 'lead-out') !== undefined; findElementBestY(elements, 0, 'immich-scrubbable-scrollbar', 'time-label', 'lead-in', 'lead-out') !== undefined;
isHover = isHoverScrollbar; isHover = isHoverScrollbar;
@ -338,6 +363,70 @@
document.addEventListener('touchend', onTouchEnd, 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> </script>
<svelte:window <svelte:window
@ -349,9 +438,10 @@
<div <div
transition:fly={{ x: 50, duration: 250 }} transition:fly={{ x: 50, duration: 250 }}
tabindex="-1" tabindex="0"
role="scrollbar" role="scrollbar"
aria-controls="time-label" aria-controls="time-label"
aria-valuetext={hoverLabel}
aria-valuenow={scrollY + PADDING_TOP} aria-valuenow={scrollY + PADDING_TOP}
aria-valuemax={toScrollY(1)} aria-valuemax={toScrollY(1)}
aria-valuemin={toScrollY(0)} aria-valuemin={toScrollY(0)}
@ -365,7 +455,7 @@
bind:this={scrollBar} bind:this={scrollBar}
onmouseenter={() => (isHover = true)} onmouseenter={() => (isHover = true)}
onmouseleave={() => (isHover = false)} onmouseleave={() => (isHover = false)}
onkeydown={(event) => onScrubKeyDown?.(event, event.currentTarget)} onkeydown={keydown}
draggable="false" draggable="false"
> >
{#if !usingMobileDevice && hoverLabel && (isHover || isDragging)} {#if !usingMobileDevice && hoverLabel && (isHover || isDragging)}
@ -411,7 +501,7 @@
{#if !usingMobileDevice && !isDragging} {#if !usingMobileDevice && !isDragging}
<div <div
class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary" class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary"
style:top="{scrollY + PADDING_TOP}px" style:top="{scrollY + PADDING_TOP - 2}px"
> >
{#if assetStore.scrolling && scrollHoverLabel && !isHover} {#if assetStore.scrolling && scrollHoverLabel && !isHover}
<p <p

View File

@ -595,7 +595,7 @@ export class AssetStore {
#pendingChanges: PendingChange[] = []; #pendingChanges: PendingChange[] = [];
#unsubscribers: Unsubscriber[] = []; #unsubscribers: Unsubscriber[] = [];
#rowHeight = 235; #rowHeight = $state(235);
#headerHeight = $state(49); #headerHeight = $state(49);
#gap = $state(12); #gap = $state(12);
@ -609,7 +609,11 @@ export class AssetStore {
constructor() {} constructor() {}
set headerHeight(value) { set headerHeight(value) {
if (this.#headerHeight == value) {
return;
}
this.#headerHeight = value; this.#headerHeight = value;
this.refreshLayout();
} }
get headerHeight() { get headerHeight() {
@ -617,13 +621,29 @@ export class AssetStore {
} }
set gap(value) { set gap(value) {
if (this.#gap == value) {
return;
}
this.#gap = value; this.#gap = value;
this.refreshLayout();
} }
get gap() { get gap() {
return this.#gap; return this.#gap;
} }
set rowHeight(value) {
if (this.#rowHeight == value) {
return;
}
this.#rowHeight = value;
this.refreshLayout();
}
get rowHeight() {
return this.#rowHeight;
}
set scrolling(value: boolean) { set scrolling(value: boolean) {
this.#scrolling = value; this.#scrolling = value;
if (value) { if (value) {
@ -651,7 +671,6 @@ export class AssetStore {
const changed = value !== this.#viewportWidth; const changed = value !== this.#viewportWidth;
this.#viewportWidth = value; this.#viewportWidth = value;
this.suspendTransitions = true; this.suspendTransitions = true;
this.#rowHeight = value < 850 ? 100 : 235;
// side-effect - its ok! // side-effect - its ok!
void this.#updateViewportGeometry(changed); void this.#updateViewportGeometry(changed);
} }
@ -751,7 +770,7 @@ export class AssetStore {
let topIntersectingBucket = undefined; let topIntersectingBucket = undefined;
for (const bucket of this.buckets) { for (const bucket of this.buckets) {
this.#updateIntersection(bucket); this.#updateIntersection(bucket);
if (!topIntersectingBucket && bucket.actuallyIntersecting) { if (!topIntersectingBucket && bucket.actuallyIntersecting && bucket.isLoaded) {
topIntersectingBucket = bucket; topIntersectingBucket = bucket;
} }
} }

View File

@ -0,0 +1,4 @@
const selectors =
'button:not([disabled], .hidden), [href]:not(.hidden), input:not([disabled], .hidden), select:not([disabled], .hidden), textarea:not([disabled], .hidden), [tabindex]:not([tabindex="-1"], .hidden)';
export const getFocusable = (container: ParentNode) => [...container.querySelectorAll<HTMLElement>(selectors)];