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:
2026-10-25 23:21:24 +01:00
parent fa3e58b747
commit 75ab27db10
10 changed files with 377 additions and 24 deletions
+84
View File
@@ -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 };
})();