mirror of
https://github.com/immich-app/immich.git
synced 2025-05-24 01:12:58 -04:00
Keyboard Accessible Scrubber
This commit is contained in:
parent
e6cffac6cc
commit
8706f7cfe3
@ -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),
|
||||
];
|
||||
};
|
||||
|
||||
|
@ -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) => {
|
||||
|
@ -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" />
|
||||
|
@ -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
|
||||
>
|
||||
|
@ -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(' '),
|
||||
|
@ -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">
|
||||
|
@ -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"
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
4
web/src/lib/utils/focus-util.ts
Normal file
4
web/src/lib/utils/focus-util.ts
Normal 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)];
|
Loading…
x
Reference in New Issue
Block a user