diff --git a/web/src/crate_web/ui/controls.gleam b/web/src/crate_web/ui/controls.gleam index e65a63b..5a00257 100644 --- a/web/src/crate_web/ui/controls.gleam +++ b/web/src/crate_web/ui/controls.gleam @@ -87,3 +87,25 @@ pub fn button( pub fn link_button(label: String, variant: Btn, href: String) -> Element(msg) { html.a([attr.class(btn_class(variant)), attr.href(href)], [text(label)]) } + +/// A two-tap arm/confirm button (see REMOVE on the record page, LOG OUT on +/// settings): unarmed shows `label` and fires `arm_msg`, armed swaps to +/// `confirm_label` and fires `confirm_msg`. `armed` lives in the caller's +/// model, not here, matching how the rest of this module stays state-free. +/// `attrs` is where callers pass an `attr.id` for focus-return after the +/// outside-click/timeout disarm, plus any extra classes. +pub fn confirm_button( + label: String, + confirm_label: String, + variant: Btn, + armed: Bool, + arm_msg: msg, + confirm_msg: msg, + attrs: List(attr.Attribute(msg)), +) -> Element(msg) { + let #(shown_label, on_click) = case armed { + True -> #(confirm_label, confirm_msg) + False -> #(label, arm_msg) + } + button(shown_label, variant, [event.on_click(on_click), ..attrs]) +} diff --git a/web/src/crate_web/ui/covers.gleam b/web/src/crate_web/ui/covers.gleam index 6f30eab..0d54ec9 100644 --- a/web/src/crate_web/ui/covers.gleam +++ b/web/src/crate_web/ui/covers.gleam @@ -26,13 +26,17 @@ pub fn tile_art(thumb: Option(String), title: String) -> Element(msg) { html.img([ attr.class("cover-tile__img"), attr.src(src), - attr.alt(title <> " cover"), + attr.alt(cover_alt(title)), ]), ]) None -> text(cover_initial(title)) } } +fn cover_alt(title: String) -> String { + title <> " cover" +} + pub fn cover_color(seed: String) -> String { let sum = string.to_utf_codepoints(seed) @@ -113,7 +117,9 @@ pub fn detail_cover( ]) } -/// Small thumbnail for list rows; falls back to an empty block. +/// Small thumbnail for list rows; falls back to an empty block. Kept for +/// callers with no title to hash a letter tile from (e.g. an avatar); prefer +/// `thumb_with_fallback` wherever a title is available, see its docblock. pub fn thumb(url: Option(String)) -> Element(msg) { case url { Some(src) -> @@ -122,12 +128,28 @@ pub fn thumb(url: Option(String)) -> Element(msg) { } } +/// Small thumbnail for list rows, same size class as `cover_row`'s own tile, +/// but falling back to the colour-hashed letter tile instead of an empty +/// block: the row-list equivalent of `tile_art`, for rows too plain to want +/// the full `cover_row`. +pub fn thumb_with_fallback( + url: Option(String), + seed: String, + title: String, +) -> Element(msg) { + let color = cover_color(seed) + html.div([attr.class("cover-tile cover-tile--row cover-tile--" <> color)], [ + tile_art(url, title), + ]) +} + /// A row's meta line: the format/year line appended to the artist, or just /// the artist alone when the pressing has neither. pub fn row_meta(snap: Snapshot) -> String { - case format_line(snap) { - "" -> snap.artist_display - fmt -> snap.artist_display <> " · " <> fmt + case snap.artist_display, format_line(snap) { + "", fmt -> fmt + artist, "" -> artist + artist, fmt -> artist <> " · " <> fmt } } diff --git a/web/src/crate_web/ui/nav.gleam b/web/src/crate_web/ui/nav.gleam index 48fc025..3543c54 100644 --- a/web/src/crate_web/ui/nav.gleam +++ b/web/src/crate_web/ui/nav.gleam @@ -37,16 +37,28 @@ pub fn tab( active: Bool, badge: Int, ) -> Element(msg) { - html.a([attr.class(tab_class(active)), attr.href(href)], [ - html.span([attr.class("tab__label")], [text(label), badge_view(badge)]), - html.span( - [ - attr.class(underline_class(active)), - attr.attribute("aria-hidden", "true"), - ], - [], - ), - ]) + html.a( + [attr.class(tab_class(active)), attr.href(href), ..current_attr(active)], + [ + html.span([attr.class("tab__label")], [text(label), badge_view(badge)]), + html.span( + [ + attr.class(underline_class(active)), + attr.attribute("aria-hidden", "true"), + ], + [], + ), + ], + ) +} + +/// `aria-current="page"` only on the active tab; an inactive tab carries no +/// `aria-current` at all rather than `"false"`, per the attribute's spec. +fn current_attr(active: Bool) -> List(attr.Attribute(msg)) { + case active { + True -> [attr.attribute("aria-current", "page")] + False -> [] + } } /// The center ADD slot: a square link straight into the scan flow, replacing diff --git a/web/src/crate_web/ui/states.gleam b/web/src/crate_web/ui/states.gleam index 16483fb..aac2791 100644 --- a/web/src/crate_web/ui/states.gleam +++ b/web/src/crate_web/ui/states.gleam @@ -1,30 +1,63 @@ //// Whole-page async states: the loading and failed placeholders a detail //// page shows before its content exists. +import crate_web/ui/controls as ctl +import gleam/option.{type Option, None, Some} import lustre/attribute as attr import lustre/element.{type Element, text} import lustre/element/html +import lustre/event /// A full-page "fetching" placeholder for a detail page's first load. +/// `role="status"` so screen readers announce it as it mounts. pub fn loading_page() -> Element(msg) { html.div([attr.class("page-scroll")], [ - html.p([attr.class("loading-status")], [text("◌ FETCHING…")]), + html.p([attr.class("loading-status"), attr.attribute("role", "status")], [ + text("◌ FETCHING…"), + ]), ]) } /// The "✕ COULDN'T LOAD" sticker plus its explanation; the bit every /// failed list/detail state shares, whatever it's wrapped in above. +/// `role="alert"` so a screen reader announces the failure unprompted. pub fn error_sticker(body: String) -> Element(msg) { - html.div([attr.class("error-state")], [ + error_sticker_view(body, None) +} + +/// `error_sticker` with a RETRY action wired to `retry`, for states that can +/// actually be retried in place instead of forcing the user to navigate away +/// and back. +pub fn error_sticker_retry(body: String, retry: msg) -> Element(msg) { + error_sticker_view(body, Some(retry)) +} + +fn error_sticker_view(body: String, retry: Option(msg)) -> Element(msg) { + html.div([attr.class("error-state"), attr.attribute("role", "alert")], [ html.span([attr.class("error-state__sticker")], [ text("✕ COULDN'T LOAD"), ]), html.p([attr.class("error-state__body")], [text(body)]), + retry_action(retry), ]) } +fn retry_action(retry: Option(msg)) -> Element(msg) { + case retry { + Some(retry_msg) -> + ctl.button("RETRY", ctl.Ghost, [event.on_click(retry_msg)]) + None -> element.none() + } +} + /// A full-page failure sticker for a detail page; `body` is the page-specific /// explanation shown underneath. pub fn failed_page(body: String) -> Element(msg) { html.div([attr.class("page-scroll")], [error_sticker(body)]) } + +/// `failed_page` with a RETRY action; use where the caller has something to +/// re-run (a fetch effect), not where failure is a dead end. +pub fn failed_page_retry(body: String, retry: msg) -> Element(msg) { + html.div([attr.class("page-scroll")], [error_sticker_retry(body, retry)]) +} diff --git a/web/test/controls_test.gleam b/web/test/controls_test.gleam new file mode 100644 index 0000000..a7778d7 --- /dev/null +++ b/web/test/controls_test.gleam @@ -0,0 +1,47 @@ +//// The `confirm_button` primitive: unarmed vs armed label/message swap, and +//// that caller-supplied attrs (id, extra classes) survive either state. + +import crate_web/ui/controls.{Danger, confirm_button} +import gleam/string +import lustre/attribute as attr +import lustre/element + +pub type TestMsg { + Arm + Confirmed +} + +fn render(armed: Bool) -> String { + confirm_button("REMOVE", "CONFIRM REMOVE", Danger, armed, Arm, Confirmed, [ + attr.id("remove-btn"), + ]) + |> element.to_string +} + +pub fn unarmed_shows_the_label_and_fires_arm_msg_test() { + let html = render(False) + assert string.contains(html, "REMOVE") + assert !string.contains(html, "CONFIRM REMOVE") + assert string.contains(html, "id=\"remove-btn\"") +} + +pub fn armed_swaps_to_the_confirm_label_test() { + let html = render(True) + assert string.contains(html, "CONFIRM REMOVE") + assert string.contains(html, "id=\"remove-btn\"") +} + +pub fn attrs_pass_through_regardless_of_armed_state_test() { + let html = + confirm_button( + "DISCONNECT", + "CONFIRM DISCONNECT", + Danger, + False, + Arm, + Confirmed, + [attr.class("btn--block")], + ) + |> element.to_string + assert string.contains(html, "btn--block") +} diff --git a/web/test/covers_test.gleam b/web/test/covers_test.gleam new file mode 100644 index 0000000..294704a --- /dev/null +++ b/web/test/covers_test.gleam @@ -0,0 +1,65 @@ +//// Cover fallback and alt-text unification, plus the `row_meta` separator +//// guard for artist-less snapshots. + +import crate/gen/defs.{type Snapshot, Snapshot} +import crate_web/ui/covers +import gleam/option.{None, Some} +import gleam/string +import lustre/element + +fn a_snapshot() -> Snapshot { + Snapshot( + artist_display: "Slint", + cover: None, + format: Some("LP"), + thumb_url: None, + title: "Spiderland", + year: Some(1991), + ) +} + +pub fn thumb_with_fallback_renders_a_letter_tile_when_there_is_no_image_test() { + let html = + covers.thumb_with_fallback(None, "Spiderland Slint", "Spiderland") + |> element.to_string + assert string.contains(html, "cover-tile") + assert string.contains(html, "S") +} + +pub fn thumb_with_fallback_uses_the_informative_alt_text_test() { + let html = + covers.thumb_with_fallback( + Some("https://example.test/cover.jpg"), + "Spiderland Slint", + "Spiderland", + ) + |> element.to_string + assert string.contains(html, "alt=\"Spiderland cover\"") +} + +pub fn tile_art_uses_the_informative_alt_text_test() { + let html = + covers.tile_art(Some("https://example.test/cover.jpg"), "Spiderland") + |> element.to_string + assert string.contains(html, "alt=\"Spiderland cover\"") +} + +pub fn row_meta_joins_artist_and_format_with_a_separator_test() { + assert covers.row_meta(a_snapshot()) == "Slint · LP / 1991" +} + +pub fn row_meta_omits_the_separator_when_artist_is_empty_test() { + let snap = Snapshot(..a_snapshot(), artist_display: "") + assert covers.row_meta(snap) == "LP / 1991" +} + +pub fn row_meta_omits_the_separator_when_format_and_year_are_absent_test() { + let snap = Snapshot(..a_snapshot(), format: None, year: None) + assert covers.row_meta(snap) == "Slint" +} + +pub fn row_meta_is_empty_when_everything_is_absent_test() { + let snap = + Snapshot(..a_snapshot(), artist_display: "", format: None, year: None) + assert covers.row_meta(snap) == "" +} diff --git a/web/test/nav_test.gleam b/web/test/nav_test.gleam index 9887c6b..e381d5b 100644 --- a/web/test/nav_test.gleam +++ b/web/test/nav_test.gleam @@ -52,7 +52,10 @@ pub fn crate_tab_stays_active_when_drilled_into_a_record_test() { ShelfLoaded(Own, [an_entry()]), ) let html = view.view(seeded) |> element.to_string - assert string.contains(html, "") + assert string.contains( + html, + "", + ) } pub fn browse_tab_goes_active_on_the_pressing_detail_drilldown_test() { @@ -63,7 +66,10 @@ pub fn browse_tab_goes_active_on_the_pressing_detail_drilldown_test() { pressing: PressingLoading, ) let html = view.view(seeded) |> element.to_string - assert string.contains(html, "") + assert string.contains( + html, + "", + ) } pub fn sub_page_back_control_renders_as_a_button_not_a_link_test() { diff --git a/web/test/states_test.gleam b/web/test/states_test.gleam new file mode 100644 index 0000000..03a724d --- /dev/null +++ b/web/test/states_test.gleam @@ -0,0 +1,38 @@ +//// Shared async-state primitives: the ARIA live regions on loading/error, +//// and that the retry variants wire RETRY without forcing it on callers +//// that have nothing to retry. + +import crate_web/ui/states +import gleam/string +import lustre/element + +pub type TestMsg { + Retry +} + +pub fn loading_page_announces_as_status_test() { + let html = states.loading_page() |> element.to_string + assert string.contains(html, "role=\"status\"") +} + +pub fn failed_page_announces_as_alert_test() { + let html = states.failed_page("Couldn't load.") |> element.to_string + assert string.contains(html, "role=\"alert\"") +} + +pub fn failed_page_has_no_retry_action_by_default_test() { + let html = states.failed_page("Couldn't load.") |> element.to_string + assert !string.contains(html, "RETRY") +} + +pub fn failed_page_retry_wires_the_retry_message_test() { + let html = + states.failed_page_retry("Couldn't load.", Retry) |> element.to_string + assert string.contains(html, "RETRY") + assert string.contains(html, "role=\"alert\"") +} + +pub fn error_sticker_retry_wires_the_retry_message_test() { + let html = states.error_sticker_retry("Nope.", Retry) |> element.to_string + assert string.contains(html, "RETRY") +}