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
+6
View File
@@ -156,6 +156,12 @@ body { overflow: auto; }
.endpoint-row .ep-host { flex: 1; min-width: 0; }
.endpoint-row .ep-port { width: 92px; flex: none; }
.endpoint-row .ep-sep { color: var(--muted); font-weight: 600; }
/* several picks from a list (manifest type `multiselect`) */
.field.multiselect-field { cursor: default; }
.field.multiselect-field .multiselect { display: flex; flex-direction: column; gap: 6px; }
.multiselect-row { display: flex; align-items: center; gap: 8px; cursor: pointer; }
.multiselect-empty { color: var(--muted); font-size: 12px; }
.module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; }
/* labeled config subsections (manifest field `group`) */
+67
View File
@@ -228,6 +228,73 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
field.append(span, select);
read = () => select.value;
selectEl = select;
} else if (type === 'multiselect' && (Array.isArray(spec.options) || spec.optionsRoute)) {
// Several picks from one list — a switch per option, stored as an array
// of values. A saved value the list no longer offers stays checked and
// marked, so a channel the account has left isn't dropped in silence.
field.classList.add('multiselect-field');
// The wrapper is a <label>; with several controls inside, a click on the
// wrapper would activate the FIRST one. `for=""` makes it label nothing.
field.htmlFor = '';
const span = document.createElement('span');
span.textContent = label;
const list = document.createElement('div');
list.className = 'multiselect';
const chosen = new Set((Array.isArray(value) ? value : []).map(String).filter(Boolean));
const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt);
const fill = (options) => {
for (const input of list.querySelectorAll('input')) {
if (input.checked) chosen.add(input.value);
else chosen.delete(input.value);
}
list.innerHTML = '';
const missing = [...chosen].filter((v) => !options.some((opt) => optValue(opt) === v));
const all = [...options, ...missing.map((v) => ({ value: v, label: `${v} (saved — not listed now)` }))];
for (const opt of all) {
const row = document.createElement('div');
row.className = 'multiselect-row';
const toggle = document.createElement('span');
toggle.className = 'switch';
const input = document.createElement('input');
input.type = 'checkbox';
input.value = optValue(opt);
input.checked = chosen.has(input.value);
const track = document.createElement('span');
track.className = 'track';
toggle.append(input, track);
const text = document.createElement('span');
text.className = 'switch-label';
text.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
row.append(toggle, text);
row.addEventListener('click', (e) => {
if (e.target === input) return; // the box toggles itself
input.checked = !input.checked;
input.dispatchEvent(new Event('change', { bubbles: true }));
});
list.appendChild(row);
}
if (!all.length) {
const none = document.createElement('div');
none.className = 'multiselect-empty';
none.textContent = 'Nothing to choose from yet.';
list.appendChild(none);
}
};
fill(Array.isArray(spec.options) ? spec.options : []);
if (spec.optionsRoute && moduleId) {
refetch = (query) => {
const qs = query && Object.keys(query).length ? `?${new URLSearchParams(query)}` : '';
return fetch(`/api/modules/${encodeURIComponent(moduleId)}${spec.optionsRoute}${qs}`)
.then((res) => (res.ok ? res.json() : null))
.then((body) => {
if (Array.isArray(body?.options)) fill(body.options);
})
.catch(() => { /* keep the static/saved options */ });
};
if (!spec.optionsDependsOn) refetch();
}
field.append(span, list);
read = () => [...list.querySelectorAll('input:checked')].map((input) => input.value);
} else if (type === 'color') {
// A swatch picker; the value is always a #rrggbb string.
field.classList.add('color-field');
+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, () => {
+1
View File
@@ -170,6 +170,7 @@ body.menu-hidden #topbar {
margin-top: 2px;
white-space: normal;
}
.menu-item[disabled] { opacity: 0.45; cursor: default; }
.menu-item.danger { color: var(--danger); }
.menu-note {
padding: 8px 10px;