Shell: single-instance tile entries, and a multiselect admin field

Two small pieces of contract for modules that offer several tiles from live
data:

- A `tiles()` entry may say `single: true`: at most one such tile per
  dashboard. The + picker greys the entry out once it is placed ("Already on
  this dashboard") and `addTile` refuses a second — for a control surface
  that would only confuse in duplicate, a send box say.
- Admin config gains `multiselect`: several picks from one list, the same
  `options` / `optionsRoute` as `select`, one switch per option, stored as an
  array of values. A saved value the list no longer offers stays checked and
  marked rather than being dropped in silence. The wrapper is a <label>, so
  it is told to label nothing — otherwise a click anywhere would flip the
  first switch.

Both documented in the module guide.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-14 12:56:10 -04:00
co-authored by Claude Fable 5.1
parent babe8263eb
commit 7d34604f2f
6 changed files with 98 additions and 3 deletions
+10 -2
View File
@@ -108,9 +108,10 @@ function wireDropdown(btnId, menuId, rebuild) {
return menu;
}
function menuItem(label, onClick, { sub = '', danger = false } = {}) {
function menuItem(label, onClick, { sub = '', danger = false, disabled = false } = {}) {
const item = document.createElement('button');
item.className = 'menu-item' + (danger ? ' danger' : '');
item.disabled = disabled;
const span = document.createElement('span');
span.textContent = label;
if (sub) {
@@ -706,6 +707,10 @@ function buildDivider(L, R, top, bottom) {
*/
function addTile(moduleId, entry = null) {
const man = registry.get(moduleId);
if (entry?.single && tiles.some((t) => t.module === moduleId && t.variant === String(entry.id))) {
toast(`${entry.name} is already on this dashboard`);
return;
}
const defaultSize = entry?.defaultSize || man?.defaultSize;
const size = {
w: Math.max(1, Math.min(COLS, defaultSize?.w || 4)),
@@ -1316,10 +1321,13 @@ function renderAddMenu(menu) {
title.textContent = man.name;
menu.appendChild(title);
for (const entry of entries) {
// An entry that allows one tile at most is offered greyed out once
// that tile exists — a second Send box would only confuse.
const placed = entry.single && tiles.some((t) => t.module === man.id && t.variant === String(entry.id));
menu.appendChild(menuItem(entry.name, () => {
menu.hidden = true;
addTile(man.id, entry);
}, { sub: entry.description || '' }));
}, { sub: placed ? 'Already on this dashboard' : (entry.description || ''), disabled: placed }));
}
} else {
menu.appendChild(menuItem(man.name, () => {