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:
co-authored by
Claude Fable 5.1
parent
babe8263eb
commit
7d34604f2f
@@ -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`) */
|
||||
|
||||
@@ -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
@@ -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, () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user