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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
(
|
(
|
||||||
[
|
[
|
||||||
|
|
|
||||||
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>
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
<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 %}
|
{% for brew in recent_brews %} {{ brew_card::card(brew, is_authenticated) }} {% endfor %}
|
||||||
</div>
|
</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() %}
|
||||||
|
<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
|
<div
|
||||||
id="open-bags-grid"
|
id="open-bags-grid"
|
||||||
class="flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory scrollbar-hide"
|
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 %}
|
{% for bag in open_bags %} {{ bag_card::card(bag, is_authenticated) }} {% endfor %}
|
||||||
</div>
|
</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">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue