diff --git a/web/css/14-windowing.css b/web/css/14-windowing.css index 3ebabd9..296bdd8 100644 --- a/web/css/14-windowing.css +++ b/web/css/14-windowing.css @@ -3,13 +3,29 @@ margin-top: 16px; } .load-more-count { + display: block; + width: 100%; + background: none; + border: none; + cursor: pointer; text-align: center; font: 700 11px/1.3 var(--mono); letter-spacing: 0.5px; color: var(--ink-muted); margin: 0 0 10px; + padding: 0; font-variant-numeric: tabular-nums; } +.load-more-count:disabled { + cursor: default; +} +.load-more-status { + text-align: center; + font: 700 11px/1.3 var(--mono); + letter-spacing: 0.5px; + color: var(--ink-muted); + margin: 0; +} /* small cover thumb used in result rows */ .cover { diff --git a/web/src/at_record_web/pages/crate.gleam b/web/src/at_record_web/pages/crate.gleam index 3bc5f58..95e3c67 100644 --- a/web/src/at_record_web/pages/crate.gleam +++ b/web/src/at_record_web/pages/crate.gleam @@ -15,6 +15,7 @@ import at_record_web/route import at_record_web/ui/app_bar as bar import at_record_web/ui/controls as ctl import at_record_web/ui/covers as cov +import gleam/dynamic/decode import gleam/int import gleam/list import gleam/option.{type Option} @@ -71,52 +72,97 @@ fn loaded_state( ) -> Element(Msg) { let total = list.length(items) let visible = list.take(items, window) + let shown = list.length(visible) + let has_more = shown < total || option.is_some(cursor) + let can_load_more = has_more && !loading_more html.div([attr.class("list-screen")], [ hero(hero_bar, hero_count(view, items), ctl.badge("◆ GROWING", ctl.Accent)), html.div([attr.class("body list-body")], [ toolbar(view, display_toggle(display)), - html.div([attr.class("list-scroll")], [ + html.div([attr.class("list-scroll"), on_near_bottom(can_load_more)], [ body(display, visible), - load_more(list.length(visible), total, cursor, loading_more), + loading_skeletons(display, loading_more), + load_more(shown, total, has_more, loading_more), ]), ]), bar.fab("ADD", route.to_path(Scan)), ]) } -/// Below the windowed list: a LOAD MORE button plus a "showing N of M" line, -/// while items remain past the current window (already fetched) or the -/// server has a next page (`cursor`). Pressing it while a fetch is already -/// in flight (`loading_more`) is disabled rather than hidden, so the count -/// line and layout don't jump. +/// Fires ShowMoreCrate once the container nears its bottom, unless there is +/// nothing left to fetch or a fetch is already in flight; `can_load_more` +/// folds both checks in so the decoder can just bail out early otherwise. +fn on_near_bottom(can_load_more: Bool) -> attr.Attribute(Msg) { + event.on("scroll", case can_load_more { + False -> decode.failure(ShowMoreCrate, "nothing to load") + True -> { + use top <- decode.subfield(["target", "scrollTop"], scroll_metric()) + use scroll_height <- decode.subfield( + ["target", "scrollHeight"], + scroll_metric(), + ) + use client <- decode.subfield(["target", "clientHeight"], scroll_metric()) + case top +. client >=. scroll_height -. 120.0 { + True -> decode.success(ShowMoreCrate) + False -> decode.failure(ShowMoreCrate, "not near bottom") + } + } + }) +} + +/// DOM scroll metrics come back as plain JS numbers; accept int or float. +fn scroll_metric() -> decode.Decoder(Float) { + decode.one_of(decode.float, [decode.int |> decode.map(int.to_float)]) +} + +/// Below the windowed list: a "showing N of M" line (also a subtle button, +/// so loading works without scrolling) and, while a fetch is in flight, a +/// muted "LOADING MORE" marker. Once everything is loaded this renders +/// nothing at all. fn load_more( shown: Int, total: Int, - cursor: Option(String), + has_more: Bool, loading_more: Bool, ) -> Element(Msg) { - case shown < total || option.is_some(cursor) { + case has_more { False -> element.none() True -> html.div([attr.class("load-more-block")], [ - html.p([attr.class("load-more-count")], [ - text( - "showing " <> int.to_string(shown) <> " of " <> int.to_string(total), - ), - ]), - ctl.button(load_more_label(loading_more), ctl.Ghost, [ - attr.class("btn--block"), - attr.disabled(loading_more), - event.on_click(ShowMoreCrate), - ]), + html.button( + [ + attr.class("load-more-count"), + attr.type_("button"), + attr.disabled(loading_more), + event.on_click(ShowMoreCrate), + ], + [ + text( + "showing " + <> int.to_string(shown) + <> " of " + <> int.to_string(total), + ), + ], + ), + loading_marker(loading_more), ]) } } -fn load_more_label(loading_more: Bool) -> String { +/// A dimmed skeleton row stands in for the incoming page while it loads. +fn loading_skeletons(display: Display, loading_more: Bool) -> Element(Msg) { + case loading_more, display { + True, Grid -> + html.div([attr.class("grid")], [skeleton_card(True), skeleton_card(True)]) + _, _ -> element.none() + } +} + +fn loading_marker(loading_more: Bool) -> Element(Msg) { case loading_more { - True -> "LOADING…" - False -> "LOAD MORE" + True -> html.p([attr.class("load-more-status")], [text("LOADING MORE ···")]) + False -> element.none() } } diff --git a/web/test/crate_test.gleam b/web/test/crate_test.gleam index 49394b5..584106d 100644 --- a/web/test/crate_test.gleam +++ b/web/test/crate_test.gleam @@ -227,15 +227,16 @@ pub fn crate_windows_items_and_shows_load_more_when_more_remain_test() { let seeded = Model(..logged_in(), shelf: ShelfLoaded(many_entries(31)), crate_window: 30) let html = crate.view(seeded) |> element.to_string - assert string.contains(html, "LOAD MORE") + assert string.contains(html, "class=\"load-more-count\"") assert string.contains(html, "showing 30 of 31") + assert !string.contains(html, "LOADING MORE") } pub fn crate_without_overflow_omits_load_more_test() { let seeded = Model(..logged_in(), shelf: ShelfLoaded(many_entries(5)), crate_window: 30) let html = crate.view(seeded) |> element.to_string - assert !string.contains(html, "LOAD MORE") + assert !string.contains(html, "load-more-block") } pub fn show_more_crate_grows_the_window_by_a_page_test() { @@ -359,7 +360,7 @@ pub fn load_more_shows_when_a_cursor_remains_even_without_local_overflow_test() crate_cursor: Some("owned:e4"), ) let html = crate.view(seeded) |> element.to_string - assert string.contains(html, "LOAD MORE") + assert string.contains(html, "class=\"load-more-count\"") } pub fn load_more_renders_a_disabled_loading_state_while_fetching_test() { @@ -371,7 +372,7 @@ pub fn load_more_renders_a_disabled_loading_state_while_fetching_test() { shelf_loading_more: True, ) let html = crate.view(seeded) |> element.to_string - assert string.contains(html, "LOADING…") + assert string.contains(html, "LOADING MORE") assert string.contains(html, "disabled") - assert !string.contains(html, "LOAD MORE") + assert string.contains(html, "class=\"load-more-count\"") }