Infinite scroll
Build a list that loads its next page when its end scrolls into view, with a Load more button that does the same for anyone who doesn't scroll.
Scroll to the bottom of the box, with the mouse or the arrow keys, and the next ten items arrive. Or Tab to Load more and press Enter.
module Snippets.LoadMore
open Fable.Core
open Browser.Types
open Firelight
open Firelight.Observers
open type Firelight.Lit
let private pageSize = 10
let private total = 50
// Stands in for a request for one page of results, such as /api/items?page=2.
let private fetchPage (page: int) =
promise {
do! Promise.sleep 400
return [ for i in page * pageSize + 1 .. min total ((page + 1) * pageSize) -> $"Item {i}" ]
}
[<AttachMembers>]
type LoadMoreList() as this =
inherit LitElement()
let endOfList = createRef<HTMLElement> ()
// `target = null`: observe nothing until firstUpdated hands it the end of the list.
let watcher =
IntersectionController<bool>(
this,
IntersectionControllerConfig(
target = null,
callback =
IntersectionValueCallback(fun entries _ ->
let inView = entries |> Array.exists (fun e -> e.isIntersecting)
if inView then
this.LoadMore()
inView
)
)
)
static member properties =
PropertyDeclarations.create [
"items", PropertyDeclaration<string list>(state = true)
"loading", PropertyDeclaration<bool>(state = true)
]
static member styles =
css
$$"""
:host { display: grid; gap: 0.5rem; justify-items: start; }
.scroller { height: 12rem; width: 16rem; overflow-y: auto; border: 1px solid var(--border);
border-radius: 0.5rem; padding: 0.5rem 1rem; }
ul { margin: 0; padding-left: 1.25rem; }
li { padding: 0.25rem 0; }
.scroller:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.end { height: 1px; }
button { font: inherit; padding: 0.3rem 0.9rem; }
p { margin: 0; }
"""
member val items = [ for i in 1..pageSize -> $"Item {i}" ] with get, set
member val loading = false with get, set
member this.HasMore = this.items.Length < total
member this.LoadMore() =
if not this.loading && this.HasMore then
this.loading <- true
promise {
let! more = fetchPage (this.items.Length / pageSize)
this.items <- this.items @ more
this.loading <- false
let! _ = this.updateComplete
// An observer reports changes. If the end is still in view, nothing changed, so
// observe it afresh: the observer then reports where it is now. Not if the
// component has left the page meanwhile, which stopped the observer.
endOfList.value
|> Option.iter (fun el ->
watcher.unobserve el
if this.HasMore && this.isConnected then
watcher.observe el
)
}
|> Promise.start
override _.firstUpdated _ =
endOfList.value |> Option.iter watcher.observe
override this.render() =
let label =
if not this.HasMore then $"All {total} loaded"
elif this.loading then "Loading…"
else "Load more"
// The button stays, outside the box, so it keeps the focus as the list grows and ends.
html
$"""
<div class="scroller" tabindex="0" role="region" aria-label="Items">
<ul>{this.items |> List.map (fun item -> html $"<li>{item}</li>")}</ul>
<div class="end" {ref endOfList}></div>
</div>
<button aria-disabled={not this.HasMore} @click={fun _ -> this.LoadMore()}>{label}</button>
<p role="status">Showing {this.items.Length} of {total}.</p>"""
defineElement<LoadMoreList> "my-load-more"
- Item 1
- Item 2
- Item 3
- Item 4
- Item 5
- Item 6
- Item 7
- Item 8
- Item 9
- Item 10
Showing 10 of 50.
How it works
An IntersectionController from Firelight.Observers wraps the browser's
IntersectionObserver: it starts observing when the component joins the page, stops when it
leaves, and asks the component to render when what it observes changes. Its callback turns the
observer's entries into a value, here whether the end of the list is in view, and calls LoadMore
when it is.
- What to observe. By default the controller observes the component itself.
target = nullstarts it with nothing, andfirstUpdatedhands it the element after the list, through a ref, once it exists. - No root. The observer's root is the window, but an element inside a scrolling box only counts as visible when it's scrolled into view in the box too. So the same code works for a box and for a whole page.
- Observing again. An observer reports changes. If the end of the list is still in view after
a page arrives, as on a tall screen, nothing has changed and no report comes.
LoadMorewaits for the new items to render, then stops observing the end and starts again, which makes the observer report where it is now. It skips that if the component has left the page while the request was running, since leaving stopped the observer. - One request at a time. The callback and the button both call
LoadMore, which does nothing whileloadingis set. - The keyboard. The box has
tabindex="0", so the arrow keys can scroll it, and arole="region"with a label, so a screen reader says what it is when it gets the focus. - The button. Infinite scroll alone shuts out anyone who can't scroll the box, and a reader
who tabs past the list never reaches its end. The button sits below the box, where new
items don't push it out of sight, and it stays on the page to the end, with
aria-disabledonce everything has loaded. Disabling it, or removing it, would drop the focus.
The first page is in the component's initial state, so the prerendered HTML shows it before any JavaScript runs.
Thousands of items, all loaded at once, are a different problem: render only the visible ones with Firelight.Virtualizer.
Related
- Controllers, for how the Observers controllers follow the lifecycle.
- Fetch JSON, for the request itself.