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 { getFocusable } from '$lib/utils/focus-util';
import { tick } from 'svelte';
interface Options {
@ -8,9 +9,6 @@ interface Options {
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) {
const triggerElement = document.activeElement;
@ -21,7 +19,7 @@ export function focusTrap(container: HTMLElement, options?: Options) {
};
const setInitialFocus = () => {
const focusableElement = container.querySelector<HTMLElement>(selectors);
const focusableElement = getFocusable(container)[0];
// Use tick() to ensure focus trap works correctly inside <Portal />
void tick().then(() => focusableElement?.focus());
};
@ -30,11 +28,11 @@ export function focusTrap(container: HTMLElement, options?: Options) {
setInitialFocus();
}
const getFocusableElements = (): [HTMLElement | null, HTMLElement | null] => {
const focusableElements = container.querySelectorAll<HTMLElement>(selectors);
const getFocusableElements = () => {
const focusableElements = getFocusable(container);
return [
focusableElements.item(0), //
focusableElements.item(focusableElements.length - 1),
focusableElements.at(0), //
focusableElements.at(-1),
];
};

View File

@ -595,7 +595,7 @@
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"
>
<div class="relative w-full whitespace-nowrap">
<div class="relative w-full">
{#each stackedAssets as stackedAsset (stackedAsset.id)}
<div
class={['inline-block px-1 relative transition-all pb-2']}
@ -603,6 +603,7 @@
>
<Thumbnail
imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }}
brokenAssetClass="text-xs"
dimmed={stackedAsset.id !== asset.id}
asset={stackedAsset}
onClick={(stackedAsset) => {

View File

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

View File

@ -14,7 +14,7 @@
</script>
<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:height
>

View File

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

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;
@ -41,6 +42,7 @@
showArchiveIcon?: boolean;
showStackedIcon?: boolean;
imageClass?: ClassValue;
brokenAssetClass?: ClassValue;
dimmed?: boolean;
onClick?: ((asset: AssetResponseDto) => void) | undefined;
onSelect?: ((asset: AssetResponseDto) => void) | undefined;
@ -67,6 +69,7 @@
onMouseEvent = undefined,
handleFocus = undefined,
imageClass = '',
brokenAssetClass = '',
dimmed = false,
}: Props = $props();
@ -78,6 +81,7 @@
let focussableElement: HTMLElement | undefined = $state();
let mouseOver = $state(false);
let loaded = $state(false);
let thumbError = $state(false);
$effect(() => {
if (focussed && document.activeElement !== focussableElement) {
@ -189,10 +193,10 @@
style:width="{width}px"
style:height="{height}px"
>
{#if !loaded && asset.thumbhash}
{#if (!loaded || thumbError) && asset.thumbhash}
<canvas
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
class="absolute object-cover z-10"
class="absolute object-cover z-40"
style:width="{width}px"
style:height="{height}px"
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
@ -219,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"
@ -332,12 +356,13 @@
</div>
<ImageThumbnail
class={imageClass}
{brokenAssetClass}
url={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
altText={$getAltText(asset)}
widthStyle="{width}px"
heightStyle="{height}px"
curve={selected}
onComplete={() => (loaded = true)}
onComplete={(errored) => ((loaded = true), (thumbError = errored))}
/>
{#if asset.type === AssetTypeEnum.Video}
<div class="absolute top-0 h-full w-full">

View File

@ -78,12 +78,18 @@
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 usingMobileDevice = $derived(mobileDevice.pointerCoarse);
const maxMd = $derived(mobileDevice.maxMd);
$effect(() => {
assetStore.rowHeight = maxMd ? 100 : 235;
});
const scrollTo = (top: number) => {
element?.scrollTo({ top });
@ -273,10 +279,21 @@
bucket = assetStore.buckets[i];
bucketHeight = assetStore.buckets[i].bucketHeight;
}
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;
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;
}
@ -704,6 +721,7 @@
{scrubBucketPercent}
{scrubBucket}
{onScrub}
bind:scrubberWidth
onScrubKeyDown={(evt) => {
evt.preventDefault();
let amount = 50;
@ -725,15 +743,11 @@
<!-- 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={scrubberWidth + 'px'}
tabindex="-1"
bind:clientHeight={assetStore.viewportHeight}
bind:clientWidth={null, (v) => ((assetStore.viewportWidth = v), updateSlidingWindow())}
bind:clientWidth={null, (v) => ((assetStore.viewportWidth = v - scrubberWidth), updateSlidingWindow())}
bind:this={element}
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
>
@ -767,7 +781,6 @@
style:position="absolute"
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:width="100%"
style:padding-left="10px"
>
<Skeleton height={bucket.bucketHeight - bucket.store.headerHeight} title={bucket.bucketDateFormatted} />
</div>
@ -794,6 +807,7 @@
</div>
{/if}
{/each}
<!-- spacer for leadout -->
<div
class="h-[60px]"
style:position="absolute"

View File

@ -13,7 +13,7 @@
>
{title}
</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>
<style>
@ -22,7 +22,7 @@
background-repeat: repeat;
background-size: 235px, 235px;
}
@media (max-width: 850px) {
@media (max-width: 767px) {
[data-skeleton] {
background-size: 100px, 100px;
}

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,6 +39,7 @@
onScrubKeyDown = undefined,
startScrub = undefined,
stopScrub = undefined,
scrubberWidth = $bindable(),
}: Props = $props();
let isHover = $state(false);
@ -52,24 +55,30 @@
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 '20px';
return MOBILE_WIDTH + 'px';
}
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 = (
scrubBucket: { bucketDate: string | undefined } | undefined,
@ -90,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 - (PADDING_TOP + PADDING_BOTTOM)) - 2;
return scrubOverallPercent * (height - (PADDING_TOP + PADDING_BOTTOM));
}
};
let scrollY = $derived(toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent));
@ -126,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,
@ -138,7 +147,7 @@
hasLabel: false,
hasDot: false,
};
top += segment.height;
if (i === 0) {
segment.hasDot = true;
segment.hasLabel = true;
@ -174,41 +183,59 @@
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 findElement = (elements: Element[], ...ids: string[]) => {
const findElementBestY = (elements: Element[], y: number, ...ids: string[]) => {
if (ids.length === 0) {
return undefined;
}
const result = elements.find((element) => {
const filtered = elements.filter((element) => {
if (element instanceof HTMLElement && element.dataset.id) {
return ids.includes(element.dataset.id);
}
return false;
});
return result as HTMLElement | undefined;
}) 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 element = findElement(elements, 'time-segment', 'lead-in', 'lead-out');
const bestElement = findElementBestY(elements, y, 'time-segment', 'lead-in', 'lead-out');
if (element) {
const segment = element as HTMLElement;
const sr = segment.getBoundingClientRect();
const sy = sr.y;
if (bestElement) {
const segment = bestElement.element;
const boundingClientRect = bestElement.boundingClientRect;
const sy = boundingClientRect.y;
const relativeY = y - sy;
const bucketPercentY = relativeY / sr.height;
const bucketPercentY = relativeY / boundingClientRect.height;
return {
isOnPaddingTop: false,
isOnPaddingBottom: false,
@ -218,14 +245,12 @@
}
// check if padding
const bar = findElement(elements, 'immich-scrubbable-scrollbar');
const bar = findElementBestY(elements, 0, 'immich-scrubbable-scrollbar');
let isOnPaddingTop = false;
let isOnPaddingBottom = false;
if (bar) {
const segment = bar as HTMLElement;
const sr = segment.getBoundingClientRect();
const sr = bar.boundingClientRect;
if (y < sr.top + PADDING_TOP) {
isOnPaddingTop = true;
}
@ -293,7 +318,7 @@
}
const elements = document.elementsFromPoint(touch.clientX, touch.clientY);
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;
@ -338,6 +363,70 @@
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
@ -349,9 +438,10 @@
<div
transition:fly={{ x: 50, duration: 250 }}
tabindex="-1"
tabindex="0"
role="scrollbar"
aria-controls="time-label"
aria-valuetext={hoverLabel}
aria-valuenow={scrollY + PADDING_TOP}
aria-valuemax={toScrollY(1)}
aria-valuemin={toScrollY(0)}
@ -365,7 +455,7 @@
bind:this={scrollBar}
onmouseenter={() => (isHover = true)}
onmouseleave={() => (isHover = false)}
onkeydown={(event) => onScrubKeyDown?.(event, event.currentTarget)}
onkeydown={keydown}
draggable="false"
>
{#if !usingMobileDevice && hoverLabel && (isHover || isDragging)}
@ -411,7 +501,7 @@
{#if !usingMobileDevice && !isDragging}
<div
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}
<p

View File

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