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")
+}