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:
parent
7f99ffc993
commit
585eb77799
6 changed files with 106 additions and 40 deletions
26
CLAUDE.md
26
CLAUDE.md
|
|
@ -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.
|
||||
|
||||
**`<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
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ pub(super) fn router() -> axum::Router<AppState> {
|
|||
"/components/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("/favicon-light.svg", get(favicon_light))
|
||||
.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 {
|
||||
(
|
||||
[
|
||||
|
|
|
|||
30
static/js/components/chip-scroll.js
Normal file
30
static/js/components/chip-scroll.js
Normal 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();
|
||||
}
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
></script>
|
||||
<script defer src="/components/photo-capture.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>
|
||||
{% block head %}{% endblock %}
|
||||
<script>
|
||||
|
|
|
|||
|
|
@ -108,9 +108,23 @@ is_authenticated %}
|
|||
>
|
||||
</div>
|
||||
{% if !recent_brews.is_empty() %}
|
||||
<div class="flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory scrollbar-hide">
|
||||
{% for brew in recent_brews %} {{ brew_card::card(brew, is_authenticated) }} {% endfor %}
|
||||
</div>
|
||||
<chip-scroll class="relative block">
|
||||
<button type="button" aria-label="Scroll left"
|
||||
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 %}
|
||||
<div class="relative">
|
||||
<div class="flex gap-4 overflow-hidden blur-[2px] select-none pointer-events-none" aria-hidden="true">
|
||||
|
|
@ -146,12 +160,27 @@ is_authenticated %}
|
|||
>
|
||||
</div>
|
||||
{% if !open_bags.is_empty() %}
|
||||
<div
|
||||
id="open-bags-grid"
|
||||
class="flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory scrollbar-hide"
|
||||
>
|
||||
{% for bag in open_bags %} {{ bag_card::card(bag, is_authenticated) }} {% endfor %}
|
||||
</div>
|
||||
<chip-scroll class="relative block">
|
||||
<button type="button" aria-label="Scroll left"
|
||||
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
|
||||
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 %}
|
||||
<div class="relative">
|
||||
<div class="flex gap-4 overflow-hidden blur-[2px] select-none pointer-events-none" aria-hidden="true">
|
||||
|
|
|
|||
Loading…
Reference in a new issue