feat: on:enter events and library scrollIntoView
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -15,4 +15,5 @@
|
||||
type={item.Type === 'Movie' ? 'movie' : 'series'}
|
||||
orientation="portrait"
|
||||
rating={item.CommunityRating || undefined}
|
||||
on:enter
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
15
src/lib/types/additional-svelte-typings.d.ts
vendored
Normal file
15
src/lib/types/additional-svelte-typings.d.ts
vendored
Normal 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
|
||||
// }
|
||||
}
|
||||
Reference in New Issue
Block a user