Skip to content

Commit 5a51ad3

Browse files
fix: responsive: timeline glitch and keyboard-accessible scrubber (#17556)
* fix: responsive: timeline glitch * lint * fix margin-right on mobile --------- Co-authored-by: Alex <[email protected]>
1 parent 664c992 commit 5a51ad3

File tree

8 files changed

+426
-141
lines changed

8 files changed

+426
-141
lines changed

web/src/lib/actions/focus-trap.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { shortcuts } from '$lib/actions/shortcut';
2+
import { getFocusable } from '$lib/utils/focus-util';
23
import { tick } from 'svelte';
34

45
interface Options {
@@ -8,9 +9,6 @@ interface Options {
89
active?: boolean;
910
}
1011

11-
const selectors =
12-
'button:not([disabled], .hidden), [href]:not(.hidden), input:not([disabled], .hidden), select:not([disabled], .hidden), textarea:not([disabled], .hidden), [tabindex]:not([tabindex="-1"], .hidden)';
13-
1412
export function focusTrap(container: HTMLElement, options?: Options) {
1513
const triggerElement = document.activeElement;
1614

@@ -21,7 +19,7 @@ export function focusTrap(container: HTMLElement, options?: Options) {
2119
};
2220

2321
const setInitialFocus = () => {
24-
const focusableElement = container.querySelector<HTMLElement>(selectors);
22+
const focusableElement = getFocusable(container)[0];
2523
// Use tick() to ensure focus trap works correctly inside <Portal />
2624
void tick().then(() => focusableElement?.focus());
2725
};
@@ -30,11 +28,11 @@ export function focusTrap(container: HTMLElement, options?: Options) {
3028
setInitialFocus();
3129
}
3230

33-
const getFocusableElements = (): [HTMLElement | null, HTMLElement | null] => {
34-
const focusableElements = container.querySelectorAll<HTMLElement>(selectors);
31+
const getFocusableElements = () => {
32+
const focusableElements = getFocusable(container);
3533
return [
36-
focusableElements.item(0), //
37-
focusableElements.item(focusableElements.length - 1),
34+
focusableElements.at(0), //
35+
focusableElements.at(-1),
3836
];
3937
};
4038

web/src/lib/components/assets/thumbnail/thumbnail.svelte

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import ImageThumbnail from './image-thumbnail.svelte';
2626
import VideoThumbnail from './video-thumbnail.svelte';
2727
import { onMount } from 'svelte';
28+
import { getFocusable } from '$lib/utils/focus-util';
2829
2930
interface Props {
3031
asset: AssetResponseDto;
@@ -222,10 +223,30 @@
222223
if (evt.key === 'x') {
223224
onSelect?.(asset);
224225
}
226+
if (document.activeElement === focussableElement && evt.key === 'Escape') {
227+
const focusable = getFocusable(document);
228+
const index = focusable.indexOf(focussableElement);
229+
230+
let i = index + 1;
231+
while (i !== index) {
232+
const next = focusable[i];
233+
if (next.dataset.thumbnailFocusContainer !== undefined) {
234+
if (i === focusable.length - 1) {
235+
i = 0;
236+
} else {
237+
i++;
238+
}
239+
continue;
240+
}
241+
next.focus();
242+
break;
243+
}
244+
}
225245
}}
226246
onclick={handleClick}
227247
bind:this={focussableElement}
228248
onfocus={handleFocus}
249+
data-thumbnail-focus-container
229250
data-testid="container-with-tabindex"
230251
tabindex={0}
231252
role="link"

web/src/lib/components/photos-page/asset-grid.svelte

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -78,13 +78,19 @@
7878
let scrubBucketPercent = $state(0);
7979
let scrubBucket: { bucketDate: string | undefined } | undefined = $state();
8080
let scrubOverallPercent: number = $state(0);
81+
let scrubberWidth = $state(0);
8182
8283
// 60 is the bottom spacer element at 60px
8384
let bottomSectionHeight = 60;
8485
let leadout = $state(false);
8586
87+
const maxMd = $derived(mobileDevice.maxMd);
8688
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
8789
90+
$effect(() => {
91+
assetStore.rowHeight = maxMd ? 100 : 235;
92+
});
93+
8894
const scrollTo = (top: number) => {
8995
element?.scrollTo({ top });
9096
showSkeleton = false;
@@ -162,7 +168,13 @@
162168
const updateIsScrolling = () => (assetStore.scrolling = true);
163169
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
164170
const updateSlidingWindow = () => assetStore.updateSlidingWindow(element?.scrollTop || 0);
165-
const compensateScrollCallback = (delta: number) => element?.scrollBy(0, delta);
171+
const compensateScrollCallback = ({ delta, top }: { delta?: number; top?: number }) => {
172+
if (delta) {
173+
element?.scrollBy(0, delta);
174+
} else if (top) {
175+
element?.scrollTo({ top });
176+
}
177+
};
166178
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (assetStore.topSectionHeight = height);
167179
168180
onMount(() => {
@@ -267,10 +279,21 @@
267279
bucket = assetStore.buckets[i];
268280
bucketHeight = assetStore.buckets[i].bucketHeight;
269281
}
282+
270283
let next = top - bucketHeight * maxScrollPercent;
271-
if (next < 0) {
284+
// instead of checking for < 0, add a little wiggle room for subpixel resolution
285+
if (next < -1 && bucket) {
272286
scrubBucket = bucket;
273-
scrubBucketPercent = top / (bucketHeight * maxScrollPercent);
287+
288+
// allowing next to be at least 1 may cause percent to go negative, so ensure positive percentage
289+
scrubBucketPercent = Math.max(0, top / (bucketHeight * maxScrollPercent));
290+
291+
// compensate for lost precision/rouding errors advance to the next bucket, if present
292+
if (scrubBucketPercent > 0.9999 && i + 1 < bucketsLength - 1) {
293+
scrubBucket = assetStore.buckets[i + 1];
294+
scrubBucketPercent = 0;
295+
}
296+
274297
found = true;
275298
break;
276299
}
@@ -689,7 +712,6 @@
689712

690713
{#if assetStore.buckets.length > 0}
691714
<Scrubber
692-
invisible={showSkeleton}
693715
{assetStore}
694716
height={assetStore.viewportHeight}
695717
timelineTopOffset={assetStore.topSectionHeight}
@@ -699,6 +721,7 @@
699721
{scrubBucketPercent}
700722
{scrubBucket}
701723
{onScrub}
724+
bind:scrubberWidth
702725
onScrubKeyDown={(evt) => {
703726
evt.preventDefault();
704727
let amount = 50;
@@ -720,12 +743,8 @@
720743
<!-- Right margin MUST be equal to the width of immich-scrubbable-scrollbar -->
721744
<section
722745
id="asset-grid"
723-
class={[
724-
'scrollbar-hidden h-full overflow-y-auto outline-none',
725-
{ 'm-0': isEmpty },
726-
{ 'ml-0': !isEmpty },
727-
{ 'mr-[60px]': !isEmpty && !usingMobileDevice },
728-
]}
746+
class={['scrollbar-hidden h-full overflow-y-auto outline-none', { 'm-0': isEmpty }, { 'ml-0': !isEmpty }]}
747+
style:margin-right={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
729748
tabindex="-1"
730749
bind:clientHeight={assetStore.viewportHeight}
731750
bind:clientWidth={null, (v) => ((assetStore.viewportWidth = v), updateSlidingWindow())}
@@ -763,7 +782,7 @@
763782
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
764783
style:width="100%"
765784
>
766-
<Skeleton height={bucket.bucketHeight} title={bucket.bucketDateFormatted} />
785+
<Skeleton height={bucket.bucketHeight - bucket.store.headerHeight} title={bucket.bucketDateFormatted} />
767786
</div>
768787
{:else if display}
769788
<div
@@ -788,7 +807,14 @@
788807
</div>
789808
{/if}
790809
{/each}
791-
<!-- <div class="h-[60px]" style:position="absolute" style:left="0" style:right="0" style:bottom="0"></div> -->
810+
<!-- spacer for leadout -->
811+
<div
812+
class="h-[60px]"
813+
style:position="absolute"
814+
style:left="0"
815+
style:right="0"
816+
style:transform={`translate3d(0,${assetStore.timelineHeight}px,0)`}
817+
></div>
792818
</section>
793819
</section>
794820

web/src/lib/components/photos-page/skeleton.svelte

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313
>
1414
{title}
1515
</div>
16-
<div class="animate-pulse absolute h-full ml-[10px]" style:width="calc(100% - 10px)" data-skeleton="true"></div>
16+
<div
17+
class="animate-pulse absolute h-full ml-[10px] mr-[10px]"
18+
style:width="calc(100% - 20px)"
19+
data-skeleton="true"
20+
></div>
1721
</div>
1822

1923
<style>

0 commit comments

Comments
 (0)