最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

svelte - Click on any item from page 2 onwards and it immediately jumps to the top of the list. How to conditionally retain scro

programmeradmin0浏览0评论

How to reproduce

  • View the page with enough width (say a desktop) where list and detail are shown side by side
  • Click show more, go to page 2 or 3 or any other page than 1
  • Click on any item
  • Boom, we jump straight to the top

Codesandbox Link

Here is the codesandbox clearly illustrating the issue

Tried a different virtual list library and still getting the same problem

Third attempt with svelte-virtual-scroll-list and still no luck

  • Removing that virtual list and simply looping through items doesn't have this issue

  • Not sure which file is causing it but for stackoverflow purposes, I'll share the +layout.ts and +layout.svelte file and the state file

lib/state/LatestNewsItems.svelte.ts

import type { Cursor } from '$lib/types/Cursor';
import type { NewsFilter } from '$lib/types/NewsFilter';
import type { NewsItem } from '$lib/types/NewsItem';

export class LatestNewsState {
    cursor: Cursor = $state({ lastFeedItemId: undefined, lastPubdate: undefined });
    filter: NewsFilter = $state('latest');
    mapNewsItemIdToTrue = new Map<string, boolean>();
    newsItems: NewsItem[] = $state([]);
    search: string = $state('');

    appendNewsItems(items: NewsItem[]) {
        for (let i = 0; i < items.length; i++) {
            const item = items[i];
            if (!this.mapNewsItemIdToTrue.get(item.id)) {
                this.newsItems.push(item);
                this.mapNewsItemIdToTrue.set(item.id, true);
            }
        }
        const lastItem = this.newsItems[this.newsItems.length - 1];
        if (lastItem) {
            this.cursor.lastFeedItemId = lastItem.id;
            this.cursor.lastPubdate = lastItem.pubdate;
        }
    }

    clearNewsItems() {
        this.newsItems.splice(0, this.newsItems.length);
        this.cursor.lastFeedItemId = undefined;
        this.cursor.lastPubdate = undefined;
        this.mapNewsItemIdToTrue.clear();
    }

    hasFilterOrSearchChanged(filter: NewsFilter, search: string) {
        return !this.isEqualFilter(filter) || !this.isEqualSearch(search);
    }

    isEqualFilter(filter: NewsFilter) {
        return filter === this.filter;
    }

    isEqualSearch(search: string) {
        return search === this.search;
    }
}

+layout.ts

import { browser } from '$app/environment';
import { requestProperties } from '$lib/config';
import {
    getNewsListFirstPageEndpoint,
    getNewsListWithPinnedItemFirstPageEndpoint
} from '$lib/endpoints/backend';
import { isDetailRoute } from '$lib/functions';
import { latestNewsState } from '$lib/state';
import type { NewsFilter } from '$lib/types/NewsFilter';
import type { NewsItem } from '$lib/types/NewsItem';
import type { LayoutLoad } from './$types';

const fetchNewsItemsFromCache = (
    newsItems: NewsItem[]
): Promise<{ status_code: number; data: NewsItem[] }> => {
    return new Promise((resolve) => {
        resolve({ status_code: 200, data: newsItems });
    });
};

const fetchNewsItemsFromAPI = (
    endpoint: string,
    fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
): Promise<{ status_code: number; data: NewsItem[] }> => {
    return new Promise((resolve, reject) => {
        fetch(endpoint, requestProperties)
            .then((response) => {
                if (!response.ok) {
                    throw new Error(
                        `Something went wrong while loading news items ${response.status} ${response.statusText}`
                    );
                }
                return response.json();
            })
            .then(resolve)
            .catch(reject);
    });
};

const getNewsListEndpoint = (
    filter: NewsFilter,
    id: string | undefined,
    search: string,
    title: string | undefined
) => {
    let endpoint;
    if (isDetailRoute(id, title)) {
        endpoint = getNewsListWithPinnedItemFirstPageEndpoint(filter, id, search);
    } else {
        endpoint = getNewsListFirstPageEndpoint(filter, search);
    }
    return endpoint;
};

const shouldFetchItemsFromAPI = (filter: NewsFilter, id: string | undefined, search: string) => {
    const hasFilterOrSearchChanged = latestNewsState.hasFilterOrSearchChanged(filter, search);
    const doesItemExistInCache = latestNewsState.mapNewsItemIdToTrue.has(id as string);
    return hasFilterOrSearchChanged || !doesItemExistInCache;
};

const loadOnBrowser = (
    filter: NewsFilter,
    fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,
    id: string | undefined,
    search: string,
    title: string | undefined
) => {
    let latestNewsPromise;
    if (shouldFetchItemsFromAPI(filter, id, search)) {
        console.log('oh no lets load');
        const endpoint = getNewsListEndpoint(filter, id, search, title);
        latestNewsState.clearNewsItems();
        latestNewsState.filter = filter;
        latestNewsState.search = search;
        latestNewsPromise = fetchNewsItemsFromAPI(endpoint, fetch);
    } else {
        latestNewsPromise = fetchNewsItemsFromCache(latestNewsState.newsItems);
    }
    return {
        filter,
        id,
        latestNewsPromise,
        search,
        title
    };
};

const loadOnServer = (
    filter: NewsFilter,
    fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,
    id: string | undefined,
    search: string,
    title: string | undefined
) => {
    const endpoint = getNewsListEndpoint(filter, id, search, title);
    return {
        filter,
        id,
        latestNewsPromise: fetchNewsItemsFromAPI(endpoint, fetch),
        search,
        title
    };
};

export const load: LayoutLoad = ({ fetch, params, url }) => {
    const filter = (url.searchParams.get('filter') as NewsFilter) || 'latest';
    const id = params.id;
    const search = url.searchParams.get('search') || '';
    const title = params.title;

    if (browser) {
        return loadOnBrowser(filter, fetch, id, search, title);
    } else {
        return loadOnServer(filter, fetch, id, search, title);
    }
};

+layout.svelte

<script lang="ts">
    import { VList } from 'virtua/svelte';
    import '$lib/css/main.css';
    import { page } from '$app/state';
    import { MediaQuery } from 'svelte/reactivity';
    import type { NewsItem } from '$lib/types/NewsItem.js';
    import { isDetailRoute } from '$lib/functions';
    import {
        getNewsListNextPageEndpoint,
        getNewsListWithPinnedItemNextPageEndpoint
    } from '$lib/endpoints/backend';
    import { getNewsDetailEndpoint, getNewsListEndpoint } from '$lib/endpoints/frontend.js';
    import { goto } from '$app/navigation';
    import type { NewsFilter } from '$lib/types/NewsFilter.js';
    import { latestNewsState } from '$lib/state/index.js';
    import { requestProperties } from '$lib/config/index.js';

    const large = new MediaQuery('min-width: 800px');
    const { children, data } = $props();
    const hasNoDetailSelected = $derived.by(() => {
        return (
            page.url.pathname === '/' ||
            page.url.pathname === '/news' ||
            page.url.pathname === `/news/${page.params.tag}`
        );
    });

    const filter = $derived(data.filter);
    const id = $derived(data.id);
    const search = $derived(data.search);
    const title = $derived(data.title);

    let newSearch = $state('');

    $effect(() => {
        data.latestNewsPromise
            .then((items) => {
                latestNewsState.appendNewsItems(items.data);
            })
            .catch((error: Error) => {
                console.error(`Something went wrong when loading news items ${error.message}`);
            });
    });

    async function showMore() {
        try {
            let endpoint;
            if (isDetailRoute(page.params.id, page.params.title)) {
                endpoint = getNewsListWithPinnedItemNextPageEndpoint(
                    latestNewsState.cursor,
                    filter,
                    id,
                    search
                );
            } else {
                endpoint = getNewsListNextPageEndpoint(latestNewsState.cursor, filter, search);
            }

            const response = await fetch(endpoint, requestProperties);
            if (!response.ok) {
                throw new Error(
                    `Something went wrong when loading news items on page N ${response.status} ${response.statusText}`
                );
            }
            const { data: items }: { data: NewsItem[] } = await response.json();
            latestNewsState.appendNewsItems(items);
        } catch (error) {
            console.log(
                `Something when wrong when executing show more ${error instanceof Error ? error.message : ''}`
            );
        }
    }

    function onFilterChange(e: Event) {
        const newFilterValue = (e.target as HTMLSelectElement).value;
        let to;
        if (isDetailRoute(page.params.id, page.params.title)) {
            to = getNewsDetailEndpoint(newFilterValue as NewsFilter, id, search, title);
        } else {
            to = getNewsListEndpoint(newFilterValue as NewsFilter, search);
        }
        return goto(to);
    }

    function onSearchChange(e: KeyboardEvent) {
        if (e.key === 'Enter') {
            let to;
            if (isDetailRoute(page.params.id, page.params.title)) {
                to = getNewsDetailEndpoint(filter as NewsFilter, id, newSearch, title);
            } else {
                to = getNewsListEndpoint(filter as NewsFilter, newSearch);
            }
            return goto(to);
        }
    }
</script>

<header>
    <div>
        <a data-sveltekit-preload-data="off" href="/">TestNewsApp</a>
    </div>
    <div>
        On desktop, list + detail are shown side by side, on mobile you'll see either the list or the
        detail depending on the url
    </div>
</header>

{#if large.current}
    <main style="flex-direction:row;">
        <div class="list">
            <section class="panel">
                <span>Filter: {filter}</span>
                <span>Search: {search}</span>
            </section>
            <br />
            <div class="panel">
                <section class="list-filter" onchange={onFilterChange}>
                    <select>
                        {#each ['latest', 'likes', 'dislikes', 'trending'] as filterValue}
                            <option selected={filter === filterValue}>{filterValue}</option>
                        {/each}
                    </select>
                </section>
                <section>
                    <input
                        placeholder="Search for 'china'"
                        type="search"
                        name="search"
                        value={search}
                        oninput={(e: Event) => {
                            newSearch = (e.target as HTMLInputElement).value;
                        }}
                        onkeydown={onSearchChange}
                    />
                </section>
            </div>

            <nav>
                {#await data.latestNewsPromise}
                    <span>Loading items...</span>
                {:then}
                    <VList data={latestNewsState.newsItems} getKey={(newsItem: NewsItem) => newsItem.id}>
                        {#snippet children(newsItem, index)}
                            <div class="list-item" class:selected={page.params.id === newsItem.id}>
                                <a
                                    data-sveltekit-preload-data="off"
                                    href={getNewsDetailEndpoint(filter, newsItem.id, search, newsItem.title)}
                                    >{index + 1} {newsItem.title}</a
                                >
                            </div>
                            {#if index === latestNewsState.newsItems.length - 1}
                                <footer>
                                    <button onclick={showMore}>Show More</button>
                                </footer>
                            {/if}
                        {/snippet}
                    </VList>
                {/await}
            </nav>
        </div>
        <div class="detail">
            {@render children()}
        </div>
    </main>
{:else if !large.current && hasNoDetailSelected}
    <main style="flex-direction:column;">
        <div class="list">
            <section class="panel">
                <span>Filter: {filter}</span>
                <span>Search: {search}</span>
            </section>
            <br />
            <div class="panel">
                <section class="list-filter" onchange={onFilterChange}>
                    <select>
                        {#each ['latest', 'likes', 'dislikes', 'trending'] as filterValue}
                            <option selected={filter === filterValue}>{filterValue}</option>
                        {/each}
                    </select>
                </section>
                <section>
                    <input
                        placeholder="Search for 'china'"
                        type="search"
                        name="search"
                        value={search}
                        oninput={(e: Event) => {
                            newSearch = (e.target as HTMLInputElement).value;
                        }}
                        onkeydown={onSearchChange}
                    />
                </section>
            </div>
            <nav>
                {#await data.latestNewsPromise}
                    <span>Loading items...</span>
                {:then}
                    <VList data={latestNewsState.newsItems} getKey={(newsItem: NewsItem) => newsItem.id}>
                        {#snippet children(newsItem, index)}
                            <div class="list-item" class:selected={page.params.id === newsItem.id}>
                                <a
                                    data-sveltekit-preload-data="off"
                                    href={getNewsDetailEndpoint(filter, newsItem.id, search, newsItem.title)}
                                    >{index + 1} {newsItem.title}</a
                                >
                            </div>
                            {#if index === latestNewsState.newsItems.length - 1}
                                <footer>
                                    <button onclick={showMore}>Show More</button>
                                </footer>
                            {/if}
                        {/snippet}
                    </VList>
                {/await}
            </nav>
        </div>
    </main>
{:else}
    <div class="detail">
        {@render children()}
    </div>
{/if}

<style>
    .detail {
        background-color: lightcyan;
        flex: 1;
    }
    .list {
        background-color: lightyellow;
        flex: 1;
        display: flex;
        flex-direction: column;
        padding: 1rem;
    }
    .list-item {
        border-bottom: 1px dotted lightgray;
        padding: 0.5rem 0;
    }

    .panel {
        display: flex;
        font-size: x-small;
        justify-content: space-between;
    }
    .selected {
        background-color: yellow;
    }
    footer {
        display: flex;
        justify-content: center;
    }
    main {
        background-color: lightgoldenrodyellow;
        display: flex;
        flex: 1;
        overflow: hidden;
    }
    nav {
        flex: 1;
    }
</style>

Questions

  • When filter or search is changed, scroll position should be back to 0
  • But when clicking on items, scroll position should be retained
  • How to achieve this?

与本文相关的文章

发布评论

评论列表(0)

  1. 暂无评论