fix: Custom scrollIntoView function with offsets and onFocus handlers for containers

This commit is contained in:
Aleksi Lassila
2024-04-05 12:51:11 +03:00
parent 754227737b
commit b436e809de
12 changed files with 275 additions and 162 deletions

View File

@@ -2,7 +2,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { type NavigationActions, type RevealStrategy, Selectable } from './lib/selectable';
import { type NavigationActions, type FocusHandler, Selectable } from './lib/selectable';
import classNames from 'classnames';
export let name: string = '';
@@ -12,8 +12,7 @@
export let canFocusEmpty = true;
export let trapFocus = false;
export let debugOutline = false;
export let revealStrategy: RevealStrategy | undefined = undefined; //TODO: change to on:focus
export let childrenRevealStrategy: RevealStrategy | undefined = undefined;
export let handleFocus: (selectable: Selectable) => void = () => {};
export let active = true;
@@ -23,10 +22,9 @@
.setDirection(direction === 'grid' ? 'horizontal' : direction)
.setGridColumns(gridCols)
.setNavigationActions(navigationActions)
.setRevealStrategy(revealStrategy)
.setChildrenRevealStrategy(childrenRevealStrategy)
.setTrapFocus(trapFocus)
.setCanFocusEmpty(canFocusEmpty)
.setOnFocus(handleFocus)
.getStores();
export const container = rest.container;
export const hasFocus = rest.hasFocus;

View File

@@ -7,7 +7,7 @@
import type { TitleType } from '../../types';
import Container from '../../../Container.svelte';
import { useNavigate } from 'svelte-navigator';
import { scrollWithOffset } from '../../selectable';
import { scrollIntoView } from '../../selectable';
export let tmdbId: number | undefined = undefined;
export let tvdbId: number | undefined = undefined;

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import Container from '../../Container.svelte';
import { onMount } from 'svelte';
import { scrollWithOffset } from '../selectable';
let cols: number = 1;
const calculateRows = () => {
@@ -25,7 +24,6 @@
<Container
direction="grid"
gridCols={cols}
childrenRevealStrategy={scrollWithOffset('all', 50)}
class="grid gap-x-4 gap-y-8 grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
>
<slot />

View File

@@ -3,7 +3,6 @@
import { ChevronLeft, ChevronRight } from 'radix-icons-svelte';
import classNames from 'classnames';
import Container from '../../../Container.svelte';
import { scrollWithOffset } from '../../selectable';
export let gradientFromColor = 'from-stone-950';
export let heading = '';
@@ -45,11 +44,7 @@
</div>
<div class="relative">
<Container
childrenRevealStrategy={scrollWithOffset('left', 64 + 16)}
direction="horizontal"
navigationActions={{ left: () => true }}
>
<Container direction="horizontal" navigationActions={{ left: () => true }}>
<div
class={classNames(
'flex overflow-x-scroll items-center overflow-y-visible relative scrollbar-hide',

View File

@@ -32,6 +32,7 @@
}black 5%, black 95%, ${fadeRight ? '' : 'black 100%, '}transparent 100%);`}
on:scroll={updateScrollPosition}
bind:this={element}
navigationActions={{ left: () => true }}
>
<slot />
</Container>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import Container from '../../../Container.svelte';
import HeroShowcaseBackground from './HeroBackground.svelte';
import { scrollWithOffset, Selectable } from '../../selectable';
import { scrollIntoView, Selectable } from '../../selectable';
import IconButton from '../IconButton.svelte';
import { ChevronRight } from 'radix-icons-svelte';
import PageDots from '../HeroShowcase/PageDots.svelte';
@@ -42,7 +42,6 @@
<Container class="flex-1 flex">
<HeroShowcaseBackground {urls} {index} />
<Container
revealStrategy={scrollWithOffset('up', 0)}
navigationActions={{
right: onNext,
left: onPrevious,

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import type { TmdbEpisode, TmdbSeason } from '../../apis/tmdb/tmdb-api';
import type { TmdbEpisode } from '../../apis/tmdb/tmdb-api';
import Container from '../../../Container.svelte';
import classNames from 'classnames';
import { TMDB_BACKDROP_SMALL } from '../../constants';

View File

@@ -6,7 +6,7 @@
import { tmdbApi, type TmdbSeason, type TmdbSeriesFull2 } from '../../apis/tmdb/tmdb-api';
import Carousel from '../Carousel/Carousel.svelte';
import Container from '../../../Container.svelte';
import { scrollWithOffset } from '../../selectable';
import { scrollElementIntoView, scrollIntoView } from '../../selectable';
import UICarousel from '../Carousel/UICarousel.svelte';
import classNames from 'classnames';
@@ -20,8 +20,22 @@
(series) => (series?.seasons?.length ? ([series.seasons.length] as const) : undefined)
);
const episodeContainers: Record<string, Container> = {};
function handleSelectSeason(season: TmdbSeason) {
console.log(season);
const episode = season.episodes?.[0];
if (episode) {
console.log(
episode,
episodeContainers,
`episode-${episode.id}`,
episodeContainers[`episode-${episode.id}`]
);
const selectable = episodeContainers[`episode-${episode.id}`]?.container;
if (selectable) {
selectable.focus(false);
}
}
}
</script>
@@ -35,6 +49,11 @@
let:hasFocus
class="mx-2 text-nowrap"
on:click={() => handleSelectSeason(season)}
handleFocus={(s) => {
const element = s.getHtmlElement();
if (element) scrollElementIntoView(element, { horizontal: 64 });
handleSelectSeason(season);
}}
>
<div
class={classNames({
@@ -50,9 +69,13 @@
<div class="flex">
{#each $tmdbSeasons as season}
{#each season?.episodes || [] as episode}
<div class="mx-2">
<Container
class="mx-2"
bind:this={episodeContainers[`episode-${episode.id}`]}
handleFocus={scrollIntoView({ left: 64 + 16 })}
>
<EpisodeCard {episode} />
</div>
</Container>
{/each}
{/each}
</div>

View File

@@ -16,6 +16,7 @@
import ManageMediaModal from '../ManageMedia/ManageMediaModal.svelte';
import { derived } from 'svelte/store';
import EpisodeCarousel from './EpisodeCarousel.svelte';
import { scrollIntoView } from '../../selectable';
export let id: string;
@@ -46,7 +47,10 @@
</script>
<DetachedPage>
<div class="h-screen flex flex-col py-12 px-20 relative">
<Container
class="h-screen flex flex-col py-12 px-20 relative"
handleFocus={scrollIntoView({ top: 0 })}
>
<HeroCarousel
urls={$tmdbSeries.then(
(series) =>
@@ -143,6 +147,8 @@
{/await}
</div>
</HeroCarousel>
</div>
</Container>
<Container handleFocus={scrollIntoView({ vertical: 64 })}>
<EpisodeCarousel id={Number(id)} tmdbSeries={tmdbSeriesData} />
</Container>
</DetachedPage>

View File

@@ -13,7 +13,7 @@
import CarouselPlaceholderItems from '../components/Carousel/CarouselPlaceholderItems.svelte';
import HeroShowcase from '../components/HeroShowcase/HeroShowcase.svelte';
import { getShowcasePropsFromTmdb } from '../components/HeroShowcase/HeroShowcase';
import { scrollWithOffset } from '../selectable';
import { scrollIntoView } from '../selectable';
import SidebarMargin from '../components/SidebarMargin.svelte';
const jellyfinItemsPromise = new Promise<JellyfinItem[]>((resolve) => {
@@ -75,8 +75,10 @@
</script>
<Container focusOnMount>
<div class="h-screen flex flex-col">
<div class="flex flex-col h-screen">
<div class="flex-1 flex relative px-20">
<HeroShowcase items={tmdbApi.getPopularMovies().then(getShowcasePropsFromTmdb)} />
</div>
<div class="mt-8">
<Carousel scrollClass="">
<SidebarMargin slot="title" class="mx-4">
@@ -89,30 +91,12 @@
{:then props}
<div class="w-[4.5rem] h-1 shrink-0" />
{#each props as prop (prop.tmdbId)}
<div class="m-2">
<Container class="m-2" handleFocus={scrollIntoView({ left: 64 + 16 })}>
<Card {...prop} />
</div>
</Container>
{/each}
{/await}
</Carousel>
</div>
</div>
<!-- <Carousel scrollClass="px-2 sm:px-8 2xl:px-16">-->
<!-- <div slot="title" class="text-lg font-semibold text-zinc-300">-->
<!-- {$_('discover.library')}-->
<!-- </div>-->
<!-- {#await fetchLibraryItems()}-->
<!-- <CarouselPlaceholderItems />-->
<!-- {:then props}-->
<!-- {#each props as prop (prop.tmdbId)}-->
<!-- <Container>-->
<!-- <Poster {...prop} />-->
<!-- </Container>-->
<!-- {/each}-->
<!-- {/await}-->
<!-- </Carousel>-->
<!-- <Poster-->
<!-- backdropUrl="http://192.168.0.129:8096/Items/8cc44d55dba1495a2ffcda104286d611/Images/Primary?quality=80&fillWidth=432&tag=d026e7eb1d9ba9934c8769695e396dc4"-->
<!-- />-->
<!-- <VideoPlayer jellyfinId="8cc44d55dba1495a2ffcda104286d611" />-->
</Container>

View File

@@ -10,6 +10,7 @@
import TmdbCard from '../components/Card/TmdbCard.svelte';
import Button from '../components/Button.svelte';
import { useNavigate } from 'svelte-navigator';
import { scrollIntoView } from '../selectable';
const popularMovies = tmdbApi.getPopularMovies();
const navigate = useNavigate();
@@ -32,9 +33,9 @@
{:then items}
<div class="w-[4.5rem] h-1 shrink-0" />
{#each items as item (item.id)}
<div class="m-2">
<Container class="m-2" handleFocus={scrollIntoView({ left: 64 + 16 })}>
<TmdbCard {item} />
</div>
</Container>
{/each}
{/await}
</Carousel>

View File

@@ -11,90 +11,7 @@ export type NavigationActions = {
enter?: (selectable: Selectable) => boolean;
};
export type RevealStrategy = (target: Selectable) => void;
export const scrollWithOffset =
(side: Direction | 'all' = 'all', offset = 50): RevealStrategy =>
(target) => {
function getScrollParent(node: HTMLElement): HTMLElement | undefined {
const parent = node.parentElement;
if (parent) {
if (parent.scrollHeight > parent.clientHeight || parent.scrollWidth > parent.clientWidth) {
return parent;
} else {
return getScrollParent(parent);
}
}
}
// scrollIntoView(offset = 0, direction: Direction = 'left') {
const targetHtmlElement = target.getHtmlElement();
if (targetHtmlElement) {
const boundingRect = targetHtmlElement.getBoundingClientRect();
const leftOffset = targetHtmlElement.offsetLeft;
const rightOffset = targetHtmlElement.offsetLeft + boundingRect.width;
const topOffset = targetHtmlElement.offsetTop;
const bottomOffset = targetHtmlElement.offsetTop + boundingRect.height;
const offsetParent = getScrollParent(targetHtmlElement);
if (offsetParent) {
const parentBoundingRect = offsetParent.getBoundingClientRect();
const scrollLeft = offsetParent.scrollLeft;
const scrollRight =
offsetParent.scrollLeft + Math.min(parentBoundingRect.width, window.innerWidth);
const scrollTop = offsetParent.scrollTop;
const scrollBottom =
offsetParent.scrollTop + Math.min(parentBoundingRect.height, window.innerHeight);
if (side === 'all') {
const left =
leftOffset - offset < scrollLeft
? leftOffset - offset
: rightOffset + offset > scrollRight
? rightOffset - Math.min(parentBoundingRect.width, window.innerWidth) + offset
: -1;
const top =
topOffset - offset < scrollTop
? topOffset - offset
: bottomOffset + offset > scrollBottom
? bottomOffset - Math.min(parentBoundingRect.height, window.innerHeight) + offset
: -1;
if (left !== -1 || top !== -1) {
offsetParent.scrollTo({
...(left !== -1 && { left }),
...(top !== -1 && { top }),
behavior: 'smooth'
});
}
} else if (side === 'left' || side === 'right') {
const left = {
left: leftOffset - offset,
right: rightOffset - parentBoundingRect.width + offset
}[side];
offsetParent.scrollTo({
left,
behavior: 'smooth'
});
} else if (side === 'up' || side === 'down') {
const top = {
up: topOffset - offset,
down: bottomOffset - parentBoundingRect.height + offset
}[side];
offsetParent.scrollTo({
top,
behavior: 'smooth'
});
}
}
}
};
export type FocusHandler = (target: Selectable) => void;
export class Selectable {
id: symbol;
@@ -114,8 +31,7 @@ export class Selectable {
private isInitialized: boolean = false;
private navigationActions: NavigationActions = {};
private isActive: boolean = true;
private scrollIntoView?: RevealStrategy;
private scrollChildrenIntoView?: RevealStrategy;
private onFocus?: (selectable: Selectable) => void;
private direction: FlowDirection = 'vertical';
private gridColumns: number = 0;
@@ -159,7 +75,7 @@ export class Selectable {
return this;
}
focus() {
focus(navigate: boolean = true) {
function updateFocusIndex(currentSelectable: Selectable, selectable?: Selectable) {
if (selectable) {
const index = currentSelectable.children.indexOf(selectable);
@@ -170,22 +86,19 @@ export class Selectable {
}
}
if (!get(this.hasFocusWithin)) {
if (this.scrollIntoView) this.scrollIntoView(this);
else if (this.parent?.getScrollChildrenIntoView())
this.parent?.getScrollChildrenIntoView()?.(this);
}
if (!get(this.hasFocusWithin)) this.onFocus?.(this);
if (this.children.length > 0) {
const focusIndex = get(this.focusIndex);
if (this.children[focusIndex]?.isFocusable()) {
this.children[focusIndex]?.focus();
this.children[focusIndex]?.focus(navigate);
} else {
let i = focusIndex;
while (i < this.children.length) {
if (this.children[i]?.isFocusable()) {
this.children[i]?.focus();
this.children[i]?.focus(navigate);
// this.onFocus?.(this);
return;
}
i++;
@@ -193,18 +106,20 @@ export class Selectable {
i = focusIndex - 1;
while (i >= 0) {
if (this.children[i]?.isFocusable()) {
this.children[i]?.focus();
this.children[i]?.focus(navigate);
// this.onFocus?.(this);
return;
}
i--;
}
}
} else if (this.htmlElement) {
this.htmlElement.focus({ preventScroll: true });
// this.htmlElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
// this.scrollIntoView(50);
Selectable.focusedObject.set(this);
updateFocusIndex(this);
if (navigate) {
this.htmlElement.focus({ preventScroll: true });
Selectable.focusedObject.set(this);
}
}
}
@@ -476,6 +391,10 @@ export class Selectable {
}
}
getFocusedChild() {
return this.children[get(this.focusIndex)];
}
setNavigationActions(actions: NavigationActions) {
this.navigationActions = actions;
return this;
@@ -503,20 +422,6 @@ export class Selectable {
return this.htmlElement;
}
setRevealStrategy(revealStrategy?: RevealStrategy) {
this.scrollIntoView = revealStrategy;
return this;
}
setChildrenRevealStrategy(revealStrategy?: RevealStrategy) {
this.scrollChildrenIntoView = revealStrategy;
return this;
}
getScrollChildrenIntoView() {
return this.scrollChildrenIntoView;
}
setTrapFocus(trapFocus: boolean) {
this.trapFocus = trapFocus;
return this;
@@ -526,6 +431,11 @@ export class Selectable {
this.canFocusEmpty = canFocusEmpty;
return this;
}
setOnFocus(onFocus: typeof this.onFocus) {
this.onFocus = onFocus;
return this;
}
}
export function handleKeyboardNavigation(event: KeyboardEvent) {
@@ -559,3 +469,201 @@ export function handleKeyboardNavigation(event: KeyboardEvent) {
}
// Selectable.focusedObject.subscribe(console.log);
type Offsets = Partial<
Record<
'top' | 'bottom' | 'left' | 'right' | 'horizontal' | 'vertical' | 'all',
number | undefined
>
>;
export const scrollElementIntoView = (htmlElement: HTMLElement, offsets: Offsets = { all: 16 }) => {
function getScrollParent(
node: HTMLElement,
direction: 'vertical' | 'horizontal'
): HTMLElement | undefined {
const parent = node.parentElement;
if (parent) {
if (
(direction === 'vertical' && parent.scrollHeight > parent.clientHeight) ||
(direction === 'horizontal' && parent.scrollWidth > parent.clientWidth)
) {
return parent;
} else {
return getScrollParent(parent, direction);
}
}
}
if (offsets.vertical !== undefined) {
offsets.top = offsets.vertical;
offsets.bottom = offsets.vertical;
}
if (offsets.horizontal !== undefined) {
offsets.left = offsets.horizontal;
offsets.right = offsets.horizontal;
}
if (offsets.all !== undefined) {
offsets.top = offsets.all;
offsets.bottom = offsets.all;
offsets.left = offsets.all;
offsets.right = offsets.all;
}
const boundingRect = htmlElement.getBoundingClientRect();
const verticalParent = getScrollParent(htmlElement, 'vertical');
const horizontalParent = getScrollParent(htmlElement, 'horizontal');
if (verticalParent && (offsets.top !== undefined || offsets.bottom !== undefined)) {
const parentBoundingRect = verticalParent.getBoundingClientRect();
let top = -1;
if (offsets.top !== undefined && offsets.bottom !== undefined) {
top =
boundingRect.y - parentBoundingRect.y < offsets.top
? boundingRect.y - parentBoundingRect.y + verticalParent.scrollTop - offsets.top
: boundingRect.y - parentBoundingRect.y + htmlElement.clientHeight >
verticalParent.clientHeight - offsets.bottom
? boundingRect.y -
parentBoundingRect.y +
htmlElement.clientHeight +
verticalParent.scrollTop +
offsets.bottom -
verticalParent.clientHeight
: -1;
} else if (offsets.top !== undefined) {
top = boundingRect.y - parentBoundingRect.y + verticalParent.scrollTop - offsets.top;
} else if (offsets.bottom !== undefined) {
top =
boundingRect.y -
parentBoundingRect.y +
htmlElement.clientHeight +
verticalParent.scrollTop +
offsets.bottom -
verticalParent.clientHeight;
}
if (top !== -1) {
verticalParent.scrollTo({
behavior: 'smooth',
top
});
}
}
if (horizontalParent && (offsets.left !== undefined || offsets.right !== undefined)) {
const parentBoundingRect = horizontalParent.getBoundingClientRect();
let left = -1;
if (offsets.left !== undefined && offsets.right !== undefined) {
left =
boundingRect.x - parentBoundingRect.x < offsets.left
? boundingRect.x - parentBoundingRect.x + horizontalParent.scrollLeft - offsets.left
: boundingRect.x - parentBoundingRect.x + htmlElement.clientWidth >
horizontalParent.clientWidth - offsets.right
? boundingRect.x -
parentBoundingRect.x +
htmlElement.clientWidth +
horizontalParent.scrollLeft +
offsets.right -
horizontalParent.clientWidth
: -1;
} else if (offsets.left !== undefined) {
left = boundingRect.x - parentBoundingRect.x + horizontalParent.scrollLeft - offsets.left;
} else if (offsets.right !== undefined) {
left =
boundingRect.x -
parentBoundingRect.x +
htmlElement.clientWidth +
horizontalParent.scrollLeft +
offsets.right -
horizontalParent.clientWidth;
}
if (left !== -1) {
horizontalParent.scrollTo({
behavior: 'smooth',
left
});
}
}
};
// export const _scrollElementIntoView = (
// htmlElement: HTMLElement,
// direction: 'vertical' | 'horizontal',
// offset: number = 16
// ) => {
// function getScrollParent(node: HTMLElement): HTMLElement | undefined {
// const parent = node.parentElement;
//
// if (parent) {
// if (
// (direction === 'vertical' && parent.scrollHeight > parent.clientHeight) ||
// (direction === 'horizontal' && parent.scrollWidth > parent.clientWidth)
// ) {
// return parent;
// } else {
// return getScrollParent(parent);
// }
// }
// }
//
// const boundingRect = htmlElement.getBoundingClientRect();
// const parent = getScrollParent(htmlElement);
//
// if (parent) {
// const parentBoundingRect = parent.getBoundingClientRect();
//
// const left =
// boundingRect.x - parentBoundingRect.x < offset
// ? boundingRect.x - parentBoundingRect.x + parent.scrollLeft - offset
// : boundingRect.x - parentBoundingRect.x + htmlElement.clientWidth >
// parent.clientWidth - offset
// ? boundingRect.x -
// parentBoundingRect.x +
// htmlElement.clientWidth +
// parent.scrollLeft +
// offset -
// parent.clientWidth
// : -1;
//
// const top =
// boundingRect.y - parentBoundingRect.y < offset
// ? boundingRect.y - parentBoundingRect.y + parent.scrollTop - offset
// : boundingRect.y - parentBoundingRect.y + htmlElement.clientHeight >
// parent.clientHeight - offset
// ? boundingRect.y -
// parentBoundingRect.y +
// htmlElement.clientHeight +
// parent.scrollTop +
// offset -
// parent.clientHeight
// : -1;
//
// parent.scrollTo({
// behavior: 'smooth',
// ...(top !== -1 && direction === 'vertical' && { top }),
// ...(left !== -1 && direction === 'vertical' && { left })
// });
// }
// };
// export const scrollElementIntoView = (
// htmlElement: HTMLElement,
// offsets: Partial<
// Record<'up' | 'down' | 'left' | 'right' | 'horizontal' | 'vertical' | 'all', number | undefined>
// > = { all: 16 }
// ) => {};
export const scrollIntoView: (...args: [Offsets]) => FocusHandler =
(...args) =>
(s) => {
const element = s.getHtmlElement();
if (element) {
scrollElementIntoView(element, ...args);
}
};