diff --git a/src/Container.svelte b/src/Container.svelte index ab38c54..9b831bd 100644 --- a/src/Container.svelte +++ b/src/Container.svelte @@ -2,7 +2,7 @@ @@ -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); + }} >
{#each $tmdbSeasons as season} {#each season?.episodes || [] as episode} -
+ -
+ {/each} {/each}
diff --git a/src/lib/components/SeriesPage/SeriesPage.svelte b/src/lib/components/SeriesPage/SeriesPage.svelte index 69b0422..a3703af 100644 --- a/src/lib/components/SeriesPage/SeriesPage.svelte +++ b/src/lib/components/SeriesPage/SeriesPage.svelte @@ -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 @@ -
+ @@ -143,6 +147,8 @@ {/await}
- - + + + +
diff --git a/src/lib/pages/BrowseSeriesPage.svelte b/src/lib/pages/BrowseSeriesPage.svelte index 4a72940..2f42d18 100644 --- a/src/lib/pages/BrowseSeriesPage.svelte +++ b/src/lib/pages/BrowseSeriesPage.svelte @@ -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((resolve) => { @@ -75,8 +75,10 @@ -
- +
+
+ +
@@ -89,30 +91,12 @@ {:then props}
{#each props as prop (prop.tmdbId)} -
+ -
+ {/each} {/await}
- - - - - - - - - - - - - - - - - - diff --git a/src/lib/pages/MoviesPage.svelte b/src/lib/pages/MoviesPage.svelte index 367662c..8ac6826 100644 --- a/src/lib/pages/MoviesPage.svelte +++ b/src/lib/pages/MoviesPage.svelte @@ -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}
{#each items as item (item.id)} -
+ -
+ {/each} {/await} diff --git a/src/lib/selectable.ts b/src/lib/selectable.ts index 2b0eb58..46a21e1 100644 --- a/src/lib/selectable.ts +++ b/src/lib/selectable.ts @@ -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); + } + };