feat(home): add floating chevron scroll buttons to card sections

Extract <chip-scroll> custom element into its own file and wrap
Recent Brews and Open Bags horizontal scrollers with desktop-only
chevron navigation that half-overlaps the card edges.
This commit is contained in:
Jon Seager 2026-02-08 11:51:13 +00:00
parent 7f99ffc993
commit 585eb77799
No known key found for this signature in database
6 changed files with 106 additions and 40 deletions

View file

@ -362,6 +362,32 @@ Clicking the element opens the camera/file picker, reads the file as a data URL,
Place `<button>` children with `value` and `data-display` attributes. The component handles search filtering, hidden input, and selected-value display internally. Place `<button>` children with `value` and `data-display` attributes. The component handles search filtering, hidden input, and selected-value display internally.
**`<chip-scroll>`** (`static/js/components/chip-scroll.js`):
Wraps a horizontal scroll container with floating left/right chevron buttons on desktop. Buttons auto-show/hide based on scroll position and are hidden on mobile (`max-width: 767px`). Uses `ResizeObserver` and `MutationObserver` to update when content changes (including Datastar morphs).
Required child structure:
| Selector | Purpose |
|----------|---------|
| `[data-chip-scroll]` | The scrollable container |
| `[data-scroll-left]` | Left chevron button |
| `[data-scroll-right]` | Right chevron button |
Scroll distance and button sizing are set in the template `onclick` handler, not the component — use `scrollBy({left: ±200})` for chips, `±220` for cards. For chips use `p-1` + `h-4 w-4` icons at `left-0`/`right-0`. For cards use `p-1.5` + `h-5 w-5` icons at `-left-4`/`-right-4` (half-overlapping the card edges).
**`<world-map>`** (`static/js/components/world-map.js`):
Renders an inline SVG choropleth world map colored by country counts.
| Attribute | Purpose |
|-----------|---------|
| `data-countries` | Comma-separated `ISO:count` pairs (e.g. `ET:5,CO:3`) |
| `data-max` | Maximum count value (for color scaling) |
| `data-selected` | ISO code of the currently selected country (optional) |
Uses `requestAnimationFrame` to defer rendering until after Datastar morphing completes.
### FlexiblePayload ### FlexiblePayload
Handlers accept both JSON and form data via `FlexiblePayload<T>`. When form fields don't map 1:1 to the domain `New*` struct (e.g., a roaster name needing resolution to an ID), use a `*Submission` newtype that handles the conversion. Handlers accept both JSON and form data via `FlexiblePayload<T>`. When form fields don't map 1:1 to the domain `New*` struct (e.g., a roaster name needing resolution to an ID), use a `*Submission` newtype that handles the conversion.

View file

@ -34,6 +34,7 @@ pub(super) fn router() -> axum::Router<AppState> {
"/components/searchable-select.js", "/components/searchable-select.js",
get(searchable_select_js), get(searchable_select_js),
) )
.route("/components/chip-scroll.js", get(chip_scroll_js))
.route("/components/world-map.js", get(world_map_js)) .route("/components/world-map.js", get(world_map_js))
.route("/favicon-light.svg", get(favicon_light)) .route("/favicon-light.svg", get(favicon_light))
.route("/favicon-dark.svg", get(favicon_dark)) .route("/favicon-dark.svg", get(favicon_dark))
@ -83,6 +84,16 @@ async fn searchable_select_js() -> impl IntoResponse {
) )
} }
async fn chip_scroll_js() -> impl IntoResponse {
(
[
("content-type", "application/javascript; charset=utf-8"),
("cache-control", "public, max-age=604800"),
],
include_str!("../../../../static/js/components/chip-scroll.js"),
)
}
async fn world_map_js() -> impl IntoResponse { async fn world_map_js() -> impl IntoResponse {
( (
[ [

View file

@ -0,0 +1,30 @@
customElements.define("chip-scroll", class extends HTMLElement {
connectedCallback() { this._setup(); }
_setup() {
if (this._observer) this._observer.disconnect();
const scroller = this.querySelector("[data-chip-scroll]");
const btnL = this.querySelector("[data-scroll-left]");
const btnR = this.querySelector("[data-scroll-right]");
if (!scroller || !btnL || !btnR) return;
const update = () => {
if (window.matchMedia("(max-width: 767px)").matches) {
btnL.style.display = "none";
btnR.style.display = "none";
return;
}
btnL.style.display = scroller.scrollLeft > 0 ? "flex" : "none";
btnR.style.display = scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 1 ? "flex" : "none";
};
scroller.addEventListener("scroll", update, { passive: true });
new ResizeObserver(update).observe(scroller);
this._observer = new MutationObserver(update);
this._observer.observe(scroller, { childList: true });
update();
}
});

View file

@ -94,34 +94,3 @@ customElements.define("world-map", class extends HTMLElement {
}); });
} }
}); });
customElements.define("chip-scroll", class extends HTMLElement {
connectedCallback() { this._setup(); }
_setup() {
if (this._observer) this._observer.disconnect();
const scroller = this.querySelector("[data-chip-scroll]");
const btnL = this.querySelector("[data-scroll-left]");
const btnR = this.querySelector("[data-scroll-right]");
if (!scroller || !btnL || !btnR) return;
const update = () => {
if (window.matchMedia("(max-width: 767px)").matches) {
btnL.style.display = "none";
btnR.style.display = "none";
return;
}
btnL.style.display = scroller.scrollLeft > 0 ? "flex" : "none";
btnR.style.display = scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 1 ? "flex" : "none";
};
scroller.addEventListener("scroll", update, { passive: true });
new ResizeObserver(update).observe(scroller);
this._observer = new MutationObserver(update);
this._observer.observe(scroller, { childList: true });
update();
}
});

View file

@ -27,6 +27,7 @@
></script> ></script>
<script defer src="/components/photo-capture.js"></script> <script defer src="/components/photo-capture.js"></script>
<script defer src="/components/searchable-select.js"></script> <script defer src="/components/searchable-select.js"></script>
<script defer src="/components/chip-scroll.js"></script>
<script defer src="/components/world-map.js"></script> <script defer src="/components/world-map.js"></script>
{% block head %}{% endblock %} {% block head %}{% endblock %}
<script> <script>

View file

@ -108,9 +108,23 @@ is_authenticated %}
> >
</div> </div>
{% if !recent_brews.is_empty() %} {% if !recent_brews.is_empty() %}
<div class="flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory scrollbar-hide"> <chip-scroll class="relative block">
{% for brew in recent_brews %} {{ brew_card::card(brew, is_authenticated) }} {% endfor %} <button type="button" aria-label="Scroll left"
</div> class="hidden absolute -left-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
data-scroll-left
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: -220, behavior: 'smooth'})">
{{ icons::chevron_left("h-5 w-5") }}
</button>
<div class="flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory scrollbar-hide" data-chip-scroll>
{% for brew in recent_brews %} {{ brew_card::card(brew, is_authenticated) }} {% endfor %}
</div>
<button type="button" aria-label="Scroll right"
class="hidden absolute -right-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
data-scroll-right
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: 220, behavior: 'smooth'})">
{{ icons::chevron_right("h-5 w-5") }}
</button>
</chip-scroll>
{% else %} {% else %}
<div class="relative"> <div class="relative">
<div class="flex gap-4 overflow-hidden blur-[2px] select-none pointer-events-none" aria-hidden="true"> <div class="flex gap-4 overflow-hidden blur-[2px] select-none pointer-events-none" aria-hidden="true">
@ -146,12 +160,27 @@ is_authenticated %}
> >
</div> </div>
{% if !open_bags.is_empty() %} {% if !open_bags.is_empty() %}
<div <chip-scroll class="relative block">
id="open-bags-grid" <button type="button" aria-label="Scroll left"
class="flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory scrollbar-hide" class="hidden absolute -left-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
> data-scroll-left
{% for bag in open_bags %} {{ bag_card::card(bag, is_authenticated) }} {% endfor %} onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: -220, behavior: 'smooth'})">
</div> {{ icons::chevron_left("h-5 w-5") }}
</button>
<div
id="open-bags-grid"
class="flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory scrollbar-hide"
data-chip-scroll
>
{% for bag in open_bags %} {{ bag_card::card(bag, is_authenticated) }} {% endfor %}
</div>
<button type="button" aria-label="Scroll right"
class="hidden absolute -right-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
data-scroll-right
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: 220, behavior: 'smooth'})">
{{ icons::chevron_right("h-5 w-5") }}
</button>
</chip-scroll>
{% else %} {% else %}
<div class="relative"> <div class="relative">
<div class="flex gap-4 overflow-hidden blur-[2px] select-none pointer-events-none" aria-hidden="true"> <div class="flex gap-4 overflow-hidden blur-[2px] select-none pointer-events-none" aria-hidden="true">