feat: Add spatial navigation scrollTo strategies

This commit is contained in:
Aleksi Lassila
2024-03-29 18:41:43 +02:00
parent 2656cdbc68
commit cb1f2de506
6 changed files with 146 additions and 76 deletions

View File

@@ -1,13 +1,15 @@
<script lang="ts">
import { onMount } from 'svelte';
import { type NavigationActions, Selectable } from './lib/selectable';
import { type NavigationActions, type RevealStrategy, Selectable } from './lib/selectable';
import classNames from 'classnames';
export let name: string = '';
export let direction: 'vertical' | 'horizontal' | 'grid' = 'vertical';
export let gridCols: number = 1;
export let gridCols: number = 0;
export let focusOnMount = false;
export let debugOutline = false;
export let revealStrategy: RevealStrategy | undefined = undefined;
export let childrenRevealStrategy: RevealStrategy | undefined = undefined;
export let active = true;
@@ -17,6 +19,8 @@
.setDirection(direction === 'grid' ? 'horizontal' : direction)
.setGridColumns(gridCols)
.setNavigationActions(navigationActions)
.setRevealStrategy(revealStrategy)
.setChildrenRevealStrategy(childrenRevealStrategy)
.getStores();
export const container = rest.container;
export const hasFocus = rest.hasFocus;

View File

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

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import Container from '../../Container.svelte';
import { onMount } from 'svelte';
import { scrollWithOffset } from '../selectable';
let cols: number = 1;
const calculateRows = () => {
@@ -26,6 +27,7 @@
<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,6 +3,7 @@
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 = '';
@@ -44,7 +45,7 @@
</div>
<div class="relative">
<Container direction="horizontal">
<Container childrenRevealStrategy={scrollWithOffset('left', 50)} direction="horizontal">
<div
class={classNames(
'flex overflow-x-scroll items-center overflow-y-visible relative scrollbar-hide',

View File

@@ -5,7 +5,7 @@
import { settings } from '../stores/settings.store';
import type { TitleType } from '../types';
import type { ComponentProps } from 'svelte';
import Poster from '../components/Card/Card.svelte';
import Card from '../components/Card/Card.svelte';
import { type JellyfinItem } from '../apis/jellyfin/jellyfin-api';
import { jellyfinItemsStore } from '../stores/data.store';
import Carousel from '../components/Carousel/Carousel.svelte';
@@ -13,6 +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';
const jellyfinItemsPromise = new Promise<JellyfinItem[]>((resolve) => {
jellyfinItemsStore.subscribe((data) => {
@@ -32,7 +33,7 @@
poster_path?: string;
}[],
type: TitleType | undefined = undefined
): Promise<ComponentProps<Poster>[]> => {
): Promise<ComponentProps<Card>[]> => {
const filtered = $settings.discover.excludeLibraryItems
? items.filter(
async (item) =>
@@ -84,9 +85,9 @@
<CarouselPlaceholderItems />
{:then props}
{#each props as prop (prop.tmdbId)}
<Container class="m-2">
<Poster {...prop} />
</Container>
<div class="m-2">
<Card {...prop} />
</div>
{/each}
{/await}
</Carousel>

View File

@@ -11,6 +11,91 @@ 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 class Selectable {
id: symbol;
name: string;
@@ -27,6 +112,8 @@ export class Selectable {
private isInitialized: boolean = false;
private navigationActions: NavigationActions = {};
private isActive: boolean = true;
private scrollIntoView?: RevealStrategy;
private scrollChildrenIntoView?: RevealStrategy;
private direction: FlowDirection = 'vertical';
private gridColumns: number = 0;
@@ -81,6 +168,12 @@ export class Selectable {
}
}
if (!get(this.hasFocusWithin)) {
if (this.scrollIntoView) this.scrollIntoView(this);
else if (this.parent?.getScrollChildrenIntoView())
this.parent?.getScrollChildrenIntoView()?.(this);
}
if (this.children.length > 0) {
const focusIndex = get(this.focusIndex);
@@ -107,30 +200,12 @@ export class Selectable {
} else if (this.htmlElement) {
this.htmlElement.focus({ preventScroll: true });
// this.htmlElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
this.scrollIntoView(50);
// this.scrollIntoView(50);
Selectable.focusedObject.set(this);
updateFocusIndex(this);
}
}
scrollIntoView(offset = 0, direction: Direction = 'left') {
if (this.htmlElement) {
const boundingRect = this.htmlElement.getBoundingClientRect();
const offsetParent = this.htmlElement.offsetParent as HTMLElement;
if (offsetParent) {
const left = this.htmlElement.offsetLeft - offset;
// console.log(boundingRect);
// console.log('Scrolling to left: ', left);
offsetParent.scrollTo({
left,
behavior: 'smooth'
});
}
}
}
/**
* @returns {boolean} whether the selectable is focusable
*/
@@ -150,66 +225,32 @@ export class Selectable {
return false;
}
// TODO: Clean this up
getFocusableNeighbor(direction: Direction): Selectable | undefined {
const focusIndex = get(this.focusIndex);
const isGrid = this.gridColumns > 0;
const canCycleSiblings =
(this.direction === 'vertical' &&
((direction === 'up' && focusIndex !== 0) ||
(direction === 'down' && focusIndex !== this.children.length - 1))) ||
(this.direction === 'horizontal' &&
((direction === 'left' && focusIndex !== 0) ||
(direction === 'right' && focusIndex !== this.children.length - 1))) ||
(isGrid &&
this.direction === 'horizontal' &&
((direction === 'up' && focusIndex >= this.gridColumns) ||
(direction === 'down' && focusIndex < this.children.length - this.gridColumns)));
const indexAddition = {
up: this.direction === 'vertical' ? -1 : -this.gridColumns,
down: this.direction === 'vertical' ? 1 : this.gridColumns,
left: this.direction === 'horizontal' ? -1 : -this.gridColumns,
right: this.direction === 'horizontal' ? 1 : this.gridColumns
}[direction];
if (this.children.length > 0 && canCycleSiblings) {
if (isGrid && direction === 'up') {
let index = focusIndex - this.gridColumns;
while (index >= 0) {
if (this.children[index]?.isFocusable()) {
return this.children[index];
}
index -= this.gridColumns;
}
} else if (isGrid && direction === 'down') {
let index = focusIndex + this.gridColumns;
while (index < this.children.length) {
if (this.children[index]?.isFocusable()) {
return this.children[index];
}
index += this.gridColumns;
// Cycle siblings
if (indexAddition !== 0) {
let index = focusIndex + indexAddition;
while (index >= 0 && index < this.children.length) {
if (this.children[index]?.isFocusable()) {
return this.children[index];
}
index += indexAddition;
}
}
if (direction === 'up' || direction === 'left') {
let index = focusIndex - 1;
while (index >= 0) {
if (this.children[index]?.isFocusable()) {
return this.children[index];
}
index--;
}
} else if (direction === 'down' || direction === 'right') {
let index = focusIndex + 1;
while (index < this.children.length) {
if (this.children[index]?.isFocusable()) {
return this.children[index];
}
index++;
}
}
} else if (this.neighbors[direction]?.isFocusable()) {
if (this.neighbors[direction]?.isFocusable()) {
return this.neighbors[direction];
} else {
return this.parent?.getFocusableNeighbor(direction);
}
console.warn('How did we end up here');
}
private giveFocus(direction: Direction) {
@@ -385,6 +426,24 @@ export class Selectable {
getGridColumns() {
return this.gridColumns;
}
getHtmlElement() {
return this.htmlElement;
}
setRevealStrategy(revealStrategy?: RevealStrategy) {
this.scrollIntoView = revealStrategy;
return this;
}
setChildrenRevealStrategy(revealStrategy?: RevealStrategy) {
this.scrollChildrenIntoView = revealStrategy;
return this;
}
getScrollChildrenIntoView() {
return this.scrollChildrenIntoView;
}
}
export function handleKeyboardNavigation(event: KeyboardEvent) {
@@ -424,3 +483,5 @@ export function handleKeyboardNavigation(event: KeyboardEvent) {
else currentlyFocusedObject.click();
}
}
// Selectable.focusedObject.subscribe(console.log);