Dashboard Studio 1.7.0: scroll and swipe cards, more themes, per-device dashboards
- Container cards: horizontal scroll (snap, arrows, keeps position) and swipe (dots, optional autoplay that survives redraws and respects reduced motion), with add/edit/reorder of the cards inside - Nine new themes, contrast-checked, including High contrast; bright accents get dark text - Choose which dashboard opens per device (optionally in kiosk mode) and per Home Assistant user, via /api/whoami and the ingress user headers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -831,6 +831,90 @@
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- containers
|
||||
// Scroll and swipe cards hold other cards in cfg.children. They draw placeholders;
|
||||
// ctx.renderChildren fills them using the normal widget renderer.
|
||||
const slots = (c, ctx) => (c.children || []).filter((ch) => ctx.isVisible(ch));
|
||||
const slot = (ch) => `<div class="w child" data-id="${esc(ch.id)}"></div>`;
|
||||
const noKids = (c) => `<div class="pad"><div class="empty">${(c.children || []).length ? t("None of these cards are showing right now.") : t("Add cards in the inspector.")}</div></div>`;
|
||||
|
||||
W.hscroll = {
|
||||
label: "Horizontal scroll", icon: "hscroll", size: [12, 3], container: true, transparent: true, flush: true,
|
||||
fields: [
|
||||
{ k: "title", t: "text", l: "Title", d: "" },
|
||||
{ k: "itemWidth", t: "number", l: "Card width (px)", d: 260 },
|
||||
],
|
||||
render(c, ctx) {
|
||||
const kids = slots(c, ctx);
|
||||
if (!kids.length) return noKids(c);
|
||||
return `<div class="hs">${c.title ? `<div class="wt hs-title">${esc(c.title)}</div>` : ""}
|
||||
<div class="hs-track" style="--item-w:${Math.max(120, Number(c.itemWidth) || 260)}px" tabindex="0" role="group" aria-label="${esc(c.title || t("Horizontal scroll"))}">${kids.map(slot).join("")}</div>
|
||||
<button class="hs-nav prev wbtn" data-act="hs-prev" aria-label="${t("Scroll left")}">${Icons.svg("chevron", "back")}</button>
|
||||
<button class="hs-nav next wbtn" data-act="hs-next" aria-label="${t("Scroll right")}">${Icons.svg("chevron", "fwd")}</button></div>`;
|
||||
},
|
||||
after(el, c, ctx) {
|
||||
ctx.renderChildren(el);
|
||||
const track = el.querySelector(".hs-track"), box = el.querySelector(".hs");
|
||||
if (!track) return;
|
||||
// Keep the scroll position when a card inside updates and everything is redrawn.
|
||||
track.scrollLeft = ctx.cache.scroll || 0;
|
||||
const edges = () => {
|
||||
box.classList.toggle("at-start", track.scrollLeft < 4);
|
||||
box.classList.toggle("at-end", track.scrollLeft + track.clientWidth > track.scrollWidth - 4);
|
||||
};
|
||||
track.addEventListener("scroll", () => { ctx.cache.scroll = track.scrollLeft; edges(); }, { passive: true });
|
||||
edges();
|
||||
},
|
||||
};
|
||||
|
||||
W.swipe = {
|
||||
label: "Swipe cards", icon: "swipe", size: [4, 3], container: true, transparent: true, flush: true,
|
||||
fields: [
|
||||
{ k: "autoplay", t: "number", l: "Next card every (seconds)", d: 0, ph: "0 = off" },
|
||||
{ k: "dots", t: "check", l: "Show page dots", d: true },
|
||||
],
|
||||
render(c, ctx) {
|
||||
const kids = slots(c, ctx);
|
||||
if (!kids.length) return noKids(c);
|
||||
const dots = c.dots !== false && kids.length > 1
|
||||
? `<div class="sw-dots" role="tablist" aria-label="${t("Cards")}">${kids.map((_, i) => `<button class="sw-dot wbtn" role="tab" data-act="sw-go" data-v="${i}" aria-label="${t("Card {0} of {1}", i + 1, kids.length)}" aria-selected="false"></button>`).join("")}</div>` : "";
|
||||
return `<div class="sw ${dots ? "with-dots" : ""}"><div class="sw-track">${kids.map(slot).join("")}</div>${dots}</div>`;
|
||||
},
|
||||
after(el, c, ctx) {
|
||||
ctx.renderChildren(el);
|
||||
const track = el.querySelector(".sw-track");
|
||||
if (!track) return;
|
||||
const cache = ctx.cache, n = track.children.length;
|
||||
const page = () => Math.round(track.scrollLeft / Math.max(1, track.clientWidth));
|
||||
const mark = () => el.querySelectorAll(".sw-dot").forEach((d, i) => d.setAttribute("aria-selected", String(i === page())));
|
||||
track.scrollLeft = (Math.min(cache.page || 0, n - 1)) * track.clientWidth;
|
||||
mark();
|
||||
// Only settled positions count (scrollend), so an autoplay step in flight isn't overwritten.
|
||||
track.addEventListener("scroll", mark, { passive: true });
|
||||
const settle = () => { cache.page = page(); mark(); };
|
||||
if ("onscrollend" in window) track.addEventListener("scrollend", settle);
|
||||
else { let t0; track.addEventListener("scroll", () => { clearTimeout(t0); t0 = setTimeout(settle, 150); }, { passive: true }); }
|
||||
|
||||
// One timer per card that survives redraws (cards inside update often); it always
|
||||
// works on the latest track, which each redraw records in the cache.
|
||||
cache.track = track;
|
||||
track.addEventListener("pointerdown", () => { cache.pausedUntil = Date.now() + 20000; }, { passive: true });
|
||||
const every = Math.max(3, Number(c.autoplay) || 0) * 1000;
|
||||
if (!(Number(c.autoplay) > 0) || n < 2) { clearInterval(cache.timer); cache.timer = null; return; }
|
||||
if (cache.timer && cache.every === every) return;
|
||||
clearInterval(cache.timer);
|
||||
cache.every = every;
|
||||
cache.timer = setInterval(() => {
|
||||
const tr = cache.track;
|
||||
if (!tr?.isConnected || Date.now() < (cache.pausedUntil || 0) || document.body.classList.contains("editing")) return;
|
||||
const count = tr.children.length, next = ((cache.page || 0) + 1) % count;
|
||||
cache.page = next; // a redraw mid-animation then lands on the new card, not the old one
|
||||
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
tr.scrollTo({ left: next * tr.clientWidth, behavior: reduce ? "auto" : "smooth" });
|
||||
}, every);
|
||||
},
|
||||
};
|
||||
|
||||
window.Widgets = W;
|
||||
window.HAFmt = { esc, domainOf, isOn, friendly, fmtState, icon, relTime, clockHTML, TOGGLE_DOMAINS, cap };
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user