feat: on:enter events and library scrollIntoView

This commit is contained in:
Aleksi Lassila
2024-04-06 01:03:23 +03:00
parent b7cc93691b
commit a474172de3
12 changed files with 77 additions and 44 deletions

View File

@@ -31,7 +31,7 @@
</script>
</head>
<body class="bg-stone-950 min-h-screen text-white touch-manipulation relative -z-10">
<div id="app" class="min-h-screen relative flex"></div>
<div id="app" class="h-screen w-screen overflow-hidden relative"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -26,9 +26,9 @@
</script>
<I18n />
<Container class="bg-stone-950 text-white flex flex-1 w-screen">
<Container class="w-full h-full overflow-auto bg-stone-950 text-white">
{#if $appState.user === undefined}
<div class="h-screen w-screen flex flex-col items-center justify-center">
<div class="h-full w-full flex flex-col items-center justify-center">
<div class="flex items-center justify-center hover:text-inherit selectable rounded-sm mb-2">
<div class="rounded-full bg-amber-300 h-4 w-4 mr-2" />
<h1 class="font-display uppercase font-semibold tracking-wider text-xl">Reiverr</h1>
@@ -39,7 +39,7 @@
<LoginPage />
{:else}
<Router>
<Container class="flex-1 flex flex-col min-w-0" direction="horizontal" trapFocus>
<Container class="flex flex-col" direction="horizontal" trapFocus>
<Sidebar />
<Route path="/">
<BrowseSeriesPage />

View File

@@ -2,12 +2,18 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import { type NavigationActions, type FocusHandler, Selectable } from './lib/selectable';
import {
type NavigationActions,
type FocusHandler,
Selectable,
type EnterEvent
} from './lib/selectable';
import classNames from 'classnames';
const dispatch = createEventDispatcher<{
click: MouseEvent;
select: null;
clickOrSelect: null;
enter: EnterEvent;
}>();
export let name: string = '';
@@ -17,7 +23,6 @@
export let canFocusEmpty = true;
export let trapFocus = false;
export let debugOutline = false;
export let handleFocus: FocusHandler = () => {};
export let focusOnClick = false;
export let active = true;
@@ -30,7 +35,15 @@
.setNavigationActions(handleNavigateOut)
.setTrapFocus(trapFocus)
.setCanFocusEmpty(canFocusEmpty)
.setOnFocus(handleFocus)
.setOnFocus((selectable, options) => {
function stopPropagation() {
options.propagate = false;
}
if (options.propagate) {
dispatch('enter', { selectable, options, stopPropagation });
}
})
.setOnSelect(() => {
dispatch('select');
dispatch('clickOrSelect');

View File

@@ -35,6 +35,7 @@
navigate(`${type}/${tmdbId || tvdbId}`);
}
}}
on:enter
class={classNames(
'relative flex rounded-xl selectable group hover:text-inherit flex-shrink-0 overflow-hidden text-left cursor-pointer',
{

View File

@@ -15,4 +15,5 @@
type={item.Type === 'Movie' ? 'movie' : 'series'}
orientation="portrait"
rating={item.CommunityRating || undefined}
on:enter
/>

View File

@@ -103,9 +103,10 @@
let:hasFocus
class="mx-2"
on:click={() => focusFirstEpisodeOf(season)}
handleFocus={(s, options) => {
scrollIntoView({ horizontal: 64 })(s);
if (options.setFocusedElement) focusFirstEpisodeOf(season);
on:enter={(event) => {
console.log(event);
scrollIntoView({ horizontal: 64 })(event);
if (event.detail.options.setFocusedElement) focusFirstEpisodeOf(season);
}}
bind:this={containers[`season-${season.season_number}`]}
>
@@ -129,10 +130,10 @@
<Container
class="mx-2"
bind:this={containers[`episode-${episode.id}`]}
handleFocus={(s, options) => {
scrollIntoView({ left: 64 + 16 })(s);
on:enter={(event) => {
scrollIntoView({ left: 64 + 16 })(event);
selectedTmdbEpisode = episode;
if (options.setFocusedElement) focusSeasonOf(episode);
if (event.detail.options.setFocusedElement) focusSeasonOf(episode);
}}
focusOnClick
>

View File

@@ -56,7 +56,7 @@
<ScrollHelper bind:scrollTop />
<Container
class="h-screen flex flex-col py-12 px-20 relative"
handleFocus={scrollIntoView({ top: 0 })}
on:enter={scrollIntoView({ top: 0 })}
handleNavigateOut={{
down: () => episodesSelectable?.focusChildren(1)
}}
@@ -190,7 +190,7 @@
</div>
</HeroCarousel>
</Container>
<Container handleFocus={scrollIntoView({ vertical: 64 })} bind:container={episodesSelectable}>
<Container on:enter={scrollIntoView({ vertical: 64 })} bind:container={episodesSelectable}>
<EpisodeCarousel
id={Number(id)}
tmdbSeries={tmdbSeriesData}

View File

@@ -91,7 +91,7 @@
{:then props}
<div class="w-[4.5rem] h-1 shrink-0" />
{#each props as prop (prop.tmdbId)}
<Container class="m-2" handleFocus={scrollIntoView({ left: 64 + 16 })}>
<Container class="m-2" on:enter={scrollIntoView({ left: 64 + 16 })}>
<Card {...prop} />
</Container>
{/each}

View File

@@ -6,6 +6,7 @@
import { jellyfinApi } from '../apis/jellyfin/jellyfin-api';
import CardGrid from '../components/CardGrid.svelte';
import JellyfinCard from '../components/Card/JellyfinCard.svelte';
import { scrollIntoView } from '../selectable';
const libraryItemsP = jellyfinApi.getLibraryItems();
@@ -19,10 +20,6 @@
userId: import.meta.env.VITE_JELLYFIN_USER_ID
}
}));
jellyfinItemsStore.subscribe((items) => {
console.warn('GOT ITEMS', items.data);
});
</script>
<Container focusOnMount class="pl-20">
@@ -32,7 +29,7 @@
<CarouselPlaceholderItems />
{:then items}
{#each items as item}
<JellyfinCard {item} />
<JellyfinCard {item} on:enter={scrollIntoView({ all: 64 })} />
{/each}
{/await}
</CardGrid>

View File

@@ -33,7 +33,7 @@
{:then items}
<div class="w-[4.5rem] h-1 shrink-0" />
{#each items as item (item.id)}
<Container class="m-2" handleFocus={scrollIntoView({ left: 64 + 16 })}>
<Container class="m-2" on:enter={scrollIntoView({ left: 64 + 16 })}>
<TmdbCard {item} />
</Container>
{/each}

View File

@@ -12,25 +12,23 @@ export type NavigationActions = {
enter?: (selectable: Selectable) => boolean;
};
type FocusHandlerOptions = {
type FocusEventOptions = {
setFocusedElement: boolean;
propagate: boolean;
};
export type EnterEvent = {
selectable: Selectable;
options: FocusEventOptions;
stopPropagation: () => void;
};
const createFocusHandlerOptions = (): FocusHandlerOptions => {
const options: Partial<FocusHandlerOptions> = {
setFocusedElement: true,
propagate: true
};
const createFocusHandlerOptions = (): FocusEventOptions => ({
setFocusedElement: true,
propagate: true
});
options.stopPropagation = () => {
options.propagate = false;
};
return options as FocusHandlerOptions;
};
export type FocusHandler = (selectable: Selectable, options: FocusHandlerOptions) => void;
export type FocusHandler = (selectable: Selectable, options: FocusEventOptions) => void;
export class Selectable {
id: symbol;
@@ -95,9 +93,9 @@ export class Selectable {
return this;
}
focus(options: Partial<FocusHandlerOptions> = {}) {
focus(options: Partial<FocusEventOptions> = {}) {
function propagateFocusUpdates(
options: FocusHandlerOptions,
options: FocusEventOptions,
parent: Selectable,
child?: Selectable
) {
@@ -138,7 +136,7 @@ export class Selectable {
}
}
} else if (this.htmlElement) {
const _options: FocusHandlerOptions = {
const _options: FocusEventOptions = {
...createFocusHandlerOptions(),
...options
};
@@ -151,7 +149,7 @@ export class Selectable {
}
}
focusChildren(index: number, options?: Partial<FocusHandlerOptions>): boolean {
focusChildren(index: number, options?: Partial<FocusEventOptions>): boolean {
const child = this.children[index];
if (child && child.isFocusable()) {
child.focus(options);
@@ -329,7 +327,6 @@ export class Selectable {
}
_unmountContainer() {
console.log('Unmounting selectable', this);
const isFocusedWithin = get(this.hasFocusWithin);
if (this.htmlElement) {
@@ -359,7 +356,6 @@ export class Selectable {
destroy: () => {
selectable.parent?.removeChild(selectable);
Selectable.objects.delete(htmlElement);
console.log('destroying', htmlElement, selectable);
}
};
};
@@ -547,6 +543,15 @@ export const scrollElementIntoView = (htmlElement: HTMLElement, offsets: Offsets
let top = -1;
if (offsets.top !== undefined && offsets.bottom !== undefined) {
console.log(htmlElement, verticalParent);
console.log(boundingRect, parentBoundingRect);
console.log('top', boundingRect.y - parentBoundingRect.y, '<', offsets.top);
console.log(
'bottom',
boundingRect.y - parentBoundingRect.y + htmlElement.clientHeight,
'>',
verticalParent.clientHeight - offsets.bottom
);
top =
boundingRect.y - parentBoundingRect.y < offsets.top
? boundingRect.y - parentBoundingRect.y + verticalParent.scrollTop - offsets.top
@@ -617,10 +622,10 @@ export const scrollElementIntoView = (htmlElement: HTMLElement, offsets: Offsets
}
};
export const scrollIntoView: (...args: [Offsets]) => (s: Selectable) => void =
export const scrollIntoView: (...args: [Offsets]) => (e: CustomEvent<EnterEvent>) => void =
(...args) =>
(s) => {
const element = s.getHtmlElement();
(e) => {
const element = e.detail.selectable.getHtmlElement();
if (element) {
scrollElementIntoView(element, ...args);
}

View File

@@ -0,0 +1,15 @@
import type { EnterEvent } from '../selectable';
declare namespace svelteHTML {
// enhance elements
interface IntrinsicElements {
Container: { 'on:enter': (e: EnterEvent) => void };
}
// // enhance attributes
// interface HTMLAttributes<T> {
// // If you want to use on:beforeinstallprompt
// 'on:beforeinstallprompt'?: (event: any) => any;
// // If you want to use myCustomAttribute={..} (note: all lowercase)
// mycustomattribute?: any; // You can replace any with something more specific if you like
// }
}