Shell: tile settings in a window beside the tile; schemas per tile kind
The gear popover lived inside its tile, so a tile could be too small for its own settings, and the settings sat on top of the thing they change. It is now a small window: placed beside the tile (right when there is room, else left, else over it), clamped to the viewport, moved by a grab bar, closed by clicking away, Escape, the ✕ or the gear. Settings still apply as they change — that is the point of putting the window beside the tile. With it, four pieces of contract the module work needs: - A picker entry may carry its own `instanceSchema` / `instanceGroups`: tiles added from it get settings that fit that tile, nothing else. A tile showing one ProPresenter timer has no use for a clock's format switches. - `instanceGroups[name].toggle` puts a group's on/off switch in its title row and dims the body while it is off — the admin page's convention, in the popover. - `select` and `multiselect` in tile settings may take `optionsRoute`; `multiselect` also `columns` and `allByDefault` (nothing saved until someone flips a switch, so options that turn up later are on too). - `template`: a text with placeholders built by dragging or clicking the variables a module offers into it. And the + picker's descriptions are tooltips now — the list stays a list. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
b9e895d324
commit
7a44b25d4b
+27
-5
@@ -104,7 +104,7 @@ the app); its settings are kept for a later reinstall.
|
||||
Both schemas map field names to specs:
|
||||
|
||||
```
|
||||
{ "type": "string" | "text" | "number" | "boolean" | "switch" | "select" | "multiselect" | "color" | "password" | "endpoint",
|
||||
{ "type": "string" | "text" | "number" | "boolean" | "switch" | "select" | "multiselect" | "template" | "color" | "password" | "endpoint",
|
||||
"label": "Shown next to the field",
|
||||
"default": <value>,
|
||||
"group": "Section heading", // optional; see below
|
||||
@@ -128,7 +128,17 @@ default; the tile applies it as a CSS custom property — see `pco-plan`).
|
||||
`multiselect` is several picks from one list — the same `options` /
|
||||
`optionsRoute` as `select`, rendered as one switch per option and stored as
|
||||
an array of values. A saved value the list no longer offers stays checked and
|
||||
marked, so nothing is dropped behind the admin's back (admin config only).
|
||||
marked, so nothing is dropped behind anyone's back. In a tile's gear popover
|
||||
it also takes `columns` (2–4) and `allByDefault: true`: a tile that has never
|
||||
chosen shows every option on and saves nothing until someone flips one, so
|
||||
options that turn up later are on as well (give it no `default` then).
|
||||
`select` and `multiselect` may use `optionsRoute` in `instanceSchema` too —
|
||||
the popover GETs `/api/modules/<id><optionsRoute>` like the admin page does.
|
||||
|
||||
`template` (tile settings) is a text with placeholders, built by dragging or
|
||||
clicking the variables the module offers into it: `"variables": [ { "value":
|
||||
"{series}", "label": "Series" }, … ]`. Keep `help` to the joining rules; the
|
||||
chips carry the names.
|
||||
|
||||
`text` is a multi-line string — a `<textarea>` in the admin page, stored with
|
||||
its newlines; one item per line is the convention (the `slack` module's quick
|
||||
@@ -171,9 +181,17 @@ explanation there.
|
||||
|
||||
`group` and `help` work on `instanceSchema` fields too, and a manifest
|
||||
`instanceGroups` map plays the `configGroups` role for the tile's gear
|
||||
popover (`collapsed: true | false`, `columns`, `help`). That is how a module
|
||||
with a long show/hide checklist keeps the popover usable — see `pco-plan`.
|
||||
Display choices belong there, per tile; admin config is for connections.
|
||||
popover (`collapsed: true | false`, `columns`, `help`, `toggle`). That is how
|
||||
a module with a long show/hide checklist keeps the popover usable — see
|
||||
`pco-plan`. Display choices belong there, per tile; admin config is for
|
||||
connections. The popover itself is a small window beside the tile (moved by
|
||||
its bar, closed by clicking away) so a tile never has to be big enough to
|
||||
hold its own settings, and a change can be watched as it lands.
|
||||
|
||||
A picker entry (see **Presenting multiple tiles**) may carry its own
|
||||
`instanceSchema` and `instanceGroups`: tiles added from that entry get those
|
||||
instead of the module's. A tile that shows one ProPresenter timer has no use
|
||||
for a clock's format switches — give it a schema of what applies.
|
||||
|
||||
`showWhen: "<key>"` hides a field until the boolean/`switch` field named
|
||||
`<key>` is on — e.g. the audio device and channel appear only once the LTC
|
||||
@@ -490,6 +508,10 @@ module.exports = {
|
||||
keep it fast (answer from state you already hold; don't fetch upstream on
|
||||
demand). A throw or a hang (>2 s) falls back to the classic single entry,
|
||||
so a dead upstream can't break the picker.
|
||||
- `instanceSchema` / `instanceGroups` on an entry replace the module's for
|
||||
tiles added from it — settings that fit that tile, nothing else.
|
||||
- Entry `description`s are tooltips in the + picker now, not visible lines —
|
||||
one short sentence.
|
||||
- `single: true` on an entry allows at most one such tile per dashboard: the
|
||||
picker greys the entry out once it is placed. Use it for a control surface
|
||||
that would only confuse in duplicate — a send box, a master switch.
|
||||
|
||||
+288
-42
@@ -108,10 +108,11 @@ function wireDropdown(btnId, menuId, rebuild) {
|
||||
return menu;
|
||||
}
|
||||
|
||||
function menuItem(label, onClick, { sub = '', danger = false, disabled = false } = {}) {
|
||||
function menuItem(label, onClick, { sub = '', danger = false, disabled = false, title = '' } = {}) {
|
||||
const item = document.createElement('button');
|
||||
item.className = 'menu-item' + (danger ? ' danger' : '');
|
||||
item.disabled = disabled;
|
||||
if (title) item.title = title;
|
||||
const span = document.createElement('span');
|
||||
span.textContent = label;
|
||||
if (sub) {
|
||||
@@ -277,7 +278,7 @@ function buildTile(tile) {
|
||||
|
||||
head.append(dot, title, controls);
|
||||
|
||||
const hasSettings = man && man.instanceSchema && Object.keys(man.instanceSchema).length;
|
||||
const hasSettings = man && Object.keys(tileSchema(tile, man).schema).length;
|
||||
if (hasSettings) {
|
||||
const gear = document.createElement('button');
|
||||
gear.className = 'tile-btn';
|
||||
@@ -718,7 +719,8 @@ function addTile(moduleId, entry = null) {
|
||||
};
|
||||
const spot = findSpot(size.w, size.h);
|
||||
const settings = {};
|
||||
for (const [key, spec] of Object.entries(man?.instanceSchema || {})) {
|
||||
const { schema: addSchema } = tileSchema({ variant: entry ? String(entry.id) : '' }, man);
|
||||
for (const [key, spec] of Object.entries(addSchema)) {
|
||||
if (spec && 'default' in spec) settings[key] = spec.default;
|
||||
}
|
||||
Object.assign(settings, entry?.settings || {});
|
||||
@@ -895,7 +897,7 @@ function buildModuleApi(tile, man, entry) {
|
||||
/** Per-tile settings: schema defaults overlaid with what this tile saved. */
|
||||
get instanceSettings() {
|
||||
const out = {};
|
||||
for (const [key, spec] of Object.entries(man.instanceSchema || {})) {
|
||||
for (const [key, spec] of Object.entries(tileSchema(tile, man).schema)) {
|
||||
if (spec && 'default' in spec) out[key] = spec.default;
|
||||
}
|
||||
return Object.assign(out, tile.settings || {});
|
||||
@@ -1087,6 +1089,20 @@ function colorHex(value, fallback) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** The settings a tile offers: its picker entry's own schema when it has one
|
||||
— a solo timer wants none of a full tile's switches — else the module's.
|
||||
Same for the groups that arrange them. */
|
||||
function tileSchema(tile, man) {
|
||||
const own = (v) => (v && typeof v === 'object' ? v : null);
|
||||
const entry = tile?.variant && Array.isArray(man?.tiles)
|
||||
? man.tiles.find((e) => String(e.id) === String(tile.variant))
|
||||
: null;
|
||||
return {
|
||||
schema: own(entry?.instanceSchema) || own(man?.instanceSchema) || {},
|
||||
groups: own(entry?.instanceGroups) || own(man?.instanceGroups) || {},
|
||||
};
|
||||
}
|
||||
|
||||
function closeSettingsPopovers() {
|
||||
document.querySelectorAll('.tile-settings').forEach((p) => {
|
||||
p._cleanup?.();
|
||||
@@ -1094,21 +1110,76 @@ function closeSettingsPopovers() {
|
||||
});
|
||||
}
|
||||
|
||||
/* ── the per-tile settings window ───────────────────────────────────── */
|
||||
|
||||
/* It opens beside its tile — to the right when there is room, else the
|
||||
left, else over it — and always inside the viewport. A window rather than
|
||||
something inside the tile, because a tile can be smaller than its own
|
||||
settings, and because the point of live settings is watching the tile
|
||||
change while you change them: the window must not sit on top of it. */
|
||||
function placePopover(pop, anchor) {
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const pad = 8;
|
||||
const w = pop.offsetWidth;
|
||||
const h = pop.offsetHeight;
|
||||
let left = anchor.right + pad;
|
||||
if (left + w > vw - pad) left = anchor.left - w - pad;
|
||||
if (left < pad) left = Math.min(Math.max(pad, anchor.left), Math.max(pad, vw - w - pad));
|
||||
let top = anchor.top;
|
||||
if (top + h > vh - pad) top = vh - h - pad;
|
||||
if (top < pad) top = pad;
|
||||
pop.style.left = Math.round(left) + 'px';
|
||||
pop.style.top = Math.round(top) + 'px';
|
||||
}
|
||||
|
||||
function clampPopover(pop) {
|
||||
const pad = 8;
|
||||
const maxLeft = Math.max(pad, window.innerWidth - pop.offsetWidth - pad);
|
||||
const maxTop = Math.max(pad, window.innerHeight - pop.offsetHeight - pad);
|
||||
pop.style.left = Math.min(Math.max(pad, pop.offsetLeft), maxLeft) + 'px';
|
||||
pop.style.top = Math.min(Math.max(pad, pop.offsetTop), maxTop) + 'px';
|
||||
}
|
||||
|
||||
function toggleSettingsPopover(tile, el) {
|
||||
const existing = el.querySelector('.tile-settings');
|
||||
const existing = document.querySelector(`.tile-settings[data-tile-id="${tile.id}"]`);
|
||||
closeSettingsPopovers();
|
||||
if (existing) return;
|
||||
const man = registry.get(tile.module);
|
||||
if (!man || !man.instanceSchema) return;
|
||||
const { schema, groups: groupMeta } = tileSchema(tile, man);
|
||||
if (!man || !Object.keys(schema).length) return;
|
||||
|
||||
const pop = document.createElement('div');
|
||||
pop.className = 'tile-settings';
|
||||
pop.dataset.tileId = tile.id;
|
||||
pop.setAttribute('role', 'dialog');
|
||||
pop.setAttribute('aria-label', `${tile.title || man.name} settings`);
|
||||
|
||||
// A bar to move it by, and the one control every window has.
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'tile-settings-bar';
|
||||
const barTitle = document.createElement('span');
|
||||
barTitle.className = 'tile-settings-title';
|
||||
barTitle.textContent = tile.title || man.name;
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.className = 'tile-settings-close';
|
||||
closeBtn.title = 'Close';
|
||||
closeBtn.setAttribute('aria-label', 'Close settings');
|
||||
closeBtn.textContent = '✕';
|
||||
closeBtn.addEventListener('click', () => closeSettingsPopovers());
|
||||
bar.append(barTitle, closeBtn);
|
||||
const body = document.createElement('div');
|
||||
body.className = 'tile-settings-body';
|
||||
pop.append(bar, body);
|
||||
|
||||
const inputs = new Map();
|
||||
|
||||
/* These are display choices — a text size, a filter, a colour — so they
|
||||
apply as they are changed and save with the layout. There is nothing to
|
||||
confirm, so there is no Apply. Typing is debounced because applying
|
||||
means remounting the module, which shouldn't happen per keystroke. */
|
||||
/* Display choices apply as they change and save with the layout — nothing
|
||||
to confirm, so no Apply. Typing is debounced because applying means
|
||||
remounting the module, which shouldn't happen per keystroke. */
|
||||
const same = (a, b) => a === b
|
||||
|| (Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v, i) => v === b[i]));
|
||||
let applyTimer = null;
|
||||
const commit = (delay) => {
|
||||
clearTimeout(applyTimer);
|
||||
@@ -1116,11 +1187,11 @@ function toggleSettingsPopover(tile, el) {
|
||||
applyTimer = null;
|
||||
// Only when something really moved: a text field fires `input` and then
|
||||
// `change` on the way out, and a module shouldn't be torn down twice
|
||||
// over for one edit.
|
||||
// over for one edit. A reader answering undefined has nothing to say.
|
||||
let changed = false;
|
||||
for (const [key, read] of inputs) {
|
||||
const value = read();
|
||||
if (tile.settings[key] === value) continue;
|
||||
if (value === undefined || same(tile.settings[key], value)) continue;
|
||||
tile.settings[key] = value;
|
||||
changed = true;
|
||||
}
|
||||
@@ -1132,19 +1203,31 @@ function toggleSettingsPopover(tile, el) {
|
||||
const live = (input, event = 'change', delay = 0) => {
|
||||
input.addEventListener(event, () => commit(delay));
|
||||
};
|
||||
/** Choices a module only knows at runtime, from one of its own routes. */
|
||||
const fetchOptions = async (route) => {
|
||||
try {
|
||||
const res = await fetchWithTimeout(`/api/modules/${encodeURIComponent(man.id)}${route}`, 8000);
|
||||
const data = res.ok ? await res.json() : null;
|
||||
return Array.isArray(data?.options) ? data.options : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt);
|
||||
const optLabel = (opt) => String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
|
||||
|
||||
// Fields render in schema order; a field's `group` starts a labeled
|
||||
// subsection that consecutive same-group fields share, and the manifest's
|
||||
// optional `instanceGroups[name]` adds { collapsed, columns, help } —
|
||||
// the same conventions as the admin page's config form.
|
||||
const groupMeta = man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {};
|
||||
// subsection that consecutive same-group fields share, and the groups map
|
||||
// adds { collapsed, columns, help, toggle } — the same conventions as the
|
||||
// admin page's config form.
|
||||
let groupName = null;
|
||||
let container = pop;
|
||||
let container = body;
|
||||
let current = null; // the open group: { el, title, toggleKey }
|
||||
|
||||
for (const [key, spec] of Object.entries(man.instanceSchema)) {
|
||||
for (const [key, spec] of Object.entries(schema)) {
|
||||
const type = spec?.type || 'string';
|
||||
const label = spec?.label || key;
|
||||
const current = key in (tile.settings || {}) ? tile.settings[key] : spec?.default;
|
||||
const value = key in (tile.settings || {}) ? tile.settings[key] : spec?.default;
|
||||
|
||||
const g = spec?.group || '';
|
||||
if (g !== groupName) {
|
||||
@@ -1157,33 +1240,41 @@ function toggleSettingsPopover(tile, el) {
|
||||
if (folds) group.open = !meta.collapsed;
|
||||
const title = document.createElement(folds ? 'summary' : 'div');
|
||||
title.className = 'field-group-title';
|
||||
title.textContent = g;
|
||||
const titleText = document.createElement('span');
|
||||
titleText.className = 'field-group-name';
|
||||
titleText.textContent = g;
|
||||
title.appendChild(titleText);
|
||||
group.appendChild(title);
|
||||
const body = document.createElement('div');
|
||||
body.className = 'field-group-body';
|
||||
const groupBody = document.createElement('div');
|
||||
groupBody.className = 'field-group-body';
|
||||
const cols = Math.min(4, Math.trunc(Number(meta.columns)) || 0);
|
||||
if (cols > 1) {
|
||||
body.classList.add('is-columns');
|
||||
body.style.setProperty('--cols', String(cols));
|
||||
groupBody.classList.add('is-columns');
|
||||
groupBody.style.setProperty('--cols', String(cols));
|
||||
}
|
||||
if (meta.help) {
|
||||
const help = document.createElement('div');
|
||||
help.className = 'field-group-help';
|
||||
help.textContent = meta.help;
|
||||
body.appendChild(help);
|
||||
groupBody.appendChild(help);
|
||||
}
|
||||
group.appendChild(body);
|
||||
pop.appendChild(group);
|
||||
group.appendChild(groupBody);
|
||||
body.appendChild(group);
|
||||
pop.classList.add('has-groups');
|
||||
container = body;
|
||||
container = groupBody;
|
||||
current = { el: group, title, toggleKey: typeof meta.toggle === 'string' ? meta.toggle : '' };
|
||||
} else {
|
||||
container = pop;
|
||||
container = body;
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
|
||||
const isSwitch = type === 'boolean' || type === 'switch';
|
||||
const field = document.createElement('label');
|
||||
// A multiselect holds several controls, so it can't be a <label>: a click
|
||||
// anywhere in one would flip the first control.
|
||||
const field = document.createElement(type === 'multiselect' ? 'div' : 'label');
|
||||
field.className = 'field' + (isSwitch ? ' check' : '');
|
||||
let switchInput = null;
|
||||
|
||||
if (isSwitch) {
|
||||
// A switch: on is shown/enabled, off is hidden/disabled — the label
|
||||
@@ -1196,27 +1287,104 @@ function toggleSettingsPopover(tile, el) {
|
||||
toggle.className = 'switch';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.checked = Boolean(current);
|
||||
input.checked = Boolean(value);
|
||||
input.setAttribute('aria-label', label);
|
||||
const track = document.createElement('span');
|
||||
track.className = 'track';
|
||||
toggle.append(input, track);
|
||||
field.append(text, toggle);
|
||||
inputs.set(key, () => input.checked);
|
||||
live(input);
|
||||
} else if (type === 'select' && Array.isArray(spec.options)) {
|
||||
switchInput = input;
|
||||
} else if (type === 'select' && (Array.isArray(spec.options) || spec.optionsRoute)) {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = label;
|
||||
const select = document.createElement('select');
|
||||
for (const opt of spec.options) {
|
||||
const saved = value === undefined || value === null ? '' : String(value);
|
||||
const fill = (options) => {
|
||||
const wanted = select.options.length ? select.value : saved;
|
||||
select.innerHTML = '';
|
||||
// What this tile saved stays choosable even when the live list has
|
||||
// moved on (a source that is off right now, say).
|
||||
if (saved && !options.some((o) => optValue(o) === saved)) {
|
||||
options = [{ value: saved, label: `${saved} (saved)` }, ...options];
|
||||
}
|
||||
for (const opt of options) {
|
||||
const o = document.createElement('option');
|
||||
o.value = String(typeof opt === 'object' ? opt.value : opt);
|
||||
o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
|
||||
o.value = optValue(opt);
|
||||
o.textContent = optLabel(opt);
|
||||
select.appendChild(o);
|
||||
}
|
||||
select.value = String(current ?? '');
|
||||
select.value = options.some((o) => optValue(o) === wanted) ? wanted : saved;
|
||||
};
|
||||
fill(Array.isArray(spec.options) ? spec.options : []);
|
||||
if (spec.optionsRoute) fetchOptions(spec.optionsRoute).then((opts) => { if (opts) fill(opts); });
|
||||
field.append(span, select);
|
||||
inputs.set(key, () => select.value);
|
||||
live(select);
|
||||
} else if (type === 'multiselect' && (Array.isArray(spec.options) || spec.optionsRoute)) {
|
||||
// Several picks from a list, one switch each, stored as an array. With
|
||||
// `allByDefault`, a tile that has never chosen shows every option on
|
||||
// and saves nothing until someone flips one — so options that turn up
|
||||
// later are on as well, until there is an explicit list.
|
||||
field.classList.add('multiselect-field');
|
||||
const span = document.createElement('span');
|
||||
span.className = 'field-label';
|
||||
span.textContent = label;
|
||||
const list = document.createElement('div');
|
||||
list.className = 'multiselect';
|
||||
const cols = Math.min(4, Math.trunc(Number(spec.columns)) || 0);
|
||||
if (cols > 1) {
|
||||
list.classList.add('is-columns');
|
||||
list.style.setProperty('--cols', String(cols));
|
||||
}
|
||||
const saved = Array.isArray(value) ? value.map(String) : null; // null: never chosen
|
||||
let touched = false;
|
||||
const chosen = new Set(saved || []);
|
||||
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((o) => optValue(o) === v));
|
||||
const all = [...options, ...missing.map((v) => ({ value: v, label: `${v} (saved — not listed now)` }))];
|
||||
for (const opt of all) {
|
||||
const row = document.createElement('label');
|
||||
row.className = 'multiselect-row';
|
||||
const text = document.createElement('span');
|
||||
text.className = 'check-label';
|
||||
text.textContent = optLabel(opt);
|
||||
const toggle = document.createElement('span');
|
||||
toggle.className = 'switch';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.value = optValue(opt);
|
||||
input.checked = saved === null && !touched && spec.allByDefault ? true : chosen.has(input.value);
|
||||
if (input.checked) chosen.add(input.value);
|
||||
const track = document.createElement('span');
|
||||
track.className = 'track';
|
||||
toggle.append(input, track);
|
||||
row.append(text, toggle);
|
||||
input.addEventListener('change', () => {
|
||||
touched = true;
|
||||
commit(0);
|
||||
});
|
||||
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) fetchOptions(spec.optionsRoute).then((opts) => { if (opts) fill(opts); });
|
||||
field.append(span, list);
|
||||
inputs.set(key, () => (saved === null && !touched
|
||||
? undefined
|
||||
: [...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');
|
||||
@@ -1224,16 +1392,54 @@ function toggleSettingsPopover(tile, el) {
|
||||
span.textContent = label;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'color';
|
||||
input.value = colorHex(current, colorHex(spec?.default, '#2ee59a'));
|
||||
input.value = colorHex(value, colorHex(spec?.default, '#2ee59a'));
|
||||
field.append(span, input);
|
||||
inputs.set(key, () => input.value);
|
||||
live(input, 'input', 150); // dragging the picker fires continuously
|
||||
} else if (type === 'template') {
|
||||
// Text with placeholders, built by dragging (or clicking) the variables
|
||||
// the module offers into it — nobody should have to remember the names.
|
||||
field.classList.add('template-field');
|
||||
const span = document.createElement('span');
|
||||
span.textContent = label;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.spellcheck = false;
|
||||
input.value = value === undefined || value === null ? '' : String(value);
|
||||
const vars = document.createElement('div');
|
||||
vars.className = 'template-vars';
|
||||
for (const v of Array.isArray(spec.variables) ? spec.variables : []) {
|
||||
const chip = document.createElement('button');
|
||||
chip.type = 'button';
|
||||
chip.className = 'template-var';
|
||||
chip.draggable = true;
|
||||
const token = optValue(v);
|
||||
chip.textContent = optLabel(v);
|
||||
chip.title = `Drag or click to insert ${token}`;
|
||||
chip.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer.setData('text/plain', token);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
});
|
||||
chip.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const start = input.selectionStart ?? input.value.length;
|
||||
const end = input.selectionEnd ?? start;
|
||||
input.setRangeText(token, start, end, 'end');
|
||||
input.focus();
|
||||
commit(0);
|
||||
});
|
||||
vars.appendChild(chip);
|
||||
}
|
||||
field.append(span, input, vars);
|
||||
inputs.set(key, () => input.value);
|
||||
live(input, 'input', 400);
|
||||
live(input, 'change');
|
||||
} else {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = label;
|
||||
const input = document.createElement('input');
|
||||
input.type = type === 'number' ? 'number' : (type === 'password' ? 'password' : 'text');
|
||||
input.value = current === undefined || current === null ? '' : String(current);
|
||||
input.value = value === undefined || value === null ? '' : String(value);
|
||||
field.append(span, input);
|
||||
inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value));
|
||||
live(input, 'input', 400); // mid-word is not the moment to remount
|
||||
@@ -1245,23 +1451,62 @@ function toggleSettingsPopover(tile, el) {
|
||||
help.textContent = spec.help;
|
||||
field.appendChild(help);
|
||||
}
|
||||
|
||||
if (current && switchInput && key === current.toggleKey) {
|
||||
// The group's on/off lives in its title row, never in the body, and a
|
||||
// click on it must not fold a <details>.
|
||||
const grp = current;
|
||||
field.classList.add('group-toggle');
|
||||
field.addEventListener('click', (e) => e.stopPropagation());
|
||||
const sync = () => grp.el.classList.toggle('is-off', !switchInput.checked);
|
||||
switchInput.addEventListener('change', sync);
|
||||
sync();
|
||||
grp.title.appendChild(field);
|
||||
} else {
|
||||
container.appendChild(field);
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing to press: click away, press Escape, or click the gear again.
|
||||
// Nothing to press: click away, press Escape, the ✕, or the gear again.
|
||||
// (The gear's own click stops propagating, so it toggles rather than
|
||||
// closing and reopening.)
|
||||
const closeOnClick = (e) => { if (!pop.contains(e.target)) closeSettingsPopovers(); };
|
||||
const closeOnEscape = (e) => { if (e.key === 'Escape') closeSettingsPopovers(); };
|
||||
const keepInView = () => clampPopover(pop);
|
||||
pop._cleanup = () => {
|
||||
document.removeEventListener('click', closeOnClick);
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
window.removeEventListener('resize', keepInView);
|
||||
};
|
||||
document.addEventListener('click', closeOnClick);
|
||||
document.addEventListener('keydown', closeOnEscape);
|
||||
window.addEventListener('resize', keepInView);
|
||||
|
||||
// Moved by its bar.
|
||||
bar.addEventListener('pointerdown', (e) => {
|
||||
if (e.target === closeBtn || (e.button !== 0 && e.pointerType === 'mouse')) return;
|
||||
e.preventDefault();
|
||||
bar.setPointerCapture(e.pointerId);
|
||||
const dx = e.clientX - pop.offsetLeft;
|
||||
const dy = e.clientY - pop.offsetTop;
|
||||
const onMove = (ev) => {
|
||||
pop.style.left = (ev.clientX - dx) + 'px';
|
||||
pop.style.top = (ev.clientY - dy) + 'px';
|
||||
clampPopover(pop);
|
||||
};
|
||||
const onUp = () => {
|
||||
bar.removeEventListener('pointermove', onMove);
|
||||
bar.removeEventListener('pointerup', onUp);
|
||||
bar.removeEventListener('pointercancel', onUp);
|
||||
};
|
||||
bar.addEventListener('pointermove', onMove);
|
||||
bar.addEventListener('pointerup', onUp);
|
||||
bar.addEventListener('pointercancel', onUp);
|
||||
});
|
||||
|
||||
pop.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||||
el.appendChild(pop);
|
||||
document.body.appendChild(pop);
|
||||
placePopover(pop, el.getBoundingClientRect());
|
||||
}
|
||||
|
||||
/** Tear a tile's module down and start it again (settings changed). */
|
||||
@@ -1324,16 +1569,17 @@ function renderAddMenu(menu) {
|
||||
// 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));
|
||||
// Descriptions ride along as tooltips: the list stays a list.
|
||||
menu.appendChild(menuItem(entry.name, () => {
|
||||
menu.hidden = true;
|
||||
addTile(man.id, entry);
|
||||
}, { sub: placed ? 'Already on this dashboard' : (entry.description || ''), disabled: placed }));
|
||||
}, { disabled: placed, title: placed ? 'Already on this dashboard' : (entry.description || '') }));
|
||||
}
|
||||
} else {
|
||||
menu.appendChild(menuItem(man.name, () => {
|
||||
menu.hidden = true;
|
||||
addTile(man.id);
|
||||
}, { sub: man.description || '' }));
|
||||
}, { title: man.description || '' }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+95
-12
@@ -534,26 +534,109 @@ body.fullscreen #grid { padding-bottom: 0; }
|
||||
|
||||
/* ── per-tile settings popover ─────────────────────────────────────── */
|
||||
|
||||
/* A small window beside its tile (shell.js places and clamps it), not a
|
||||
box inside the tile: a tile can be smaller than its own settings, and the
|
||||
whole point of live settings is watching the tile change. */
|
||||
.tile-settings {
|
||||
position: absolute;
|
||||
top: 36px;
|
||||
right: 6px;
|
||||
z-index: 45;
|
||||
min-width: 220px;
|
||||
max-width: calc(100% - 12px);
|
||||
max-height: calc(100% - 40px);
|
||||
overflow-y: auto;
|
||||
position: fixed;
|
||||
z-index: 80;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 260px;
|
||||
max-width: min(560px, calc(100vw - 16px));
|
||||
max-height: calc(100vh - 16px);
|
||||
overflow: hidden;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.tile-settings-bar {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 6px 6px 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.tile-settings-bar:active { cursor: grabbing; }
|
||||
.tile-settings-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.tile-settings-close {
|
||||
flex: none;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tile-settings-close:hover { background: rgba(255, 255, 255, 0.08); color: var(--text); }
|
||||
.tile-settings-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
/* a group's on/off sits in its title row; the body dims while it is off */
|
||||
.tile-settings .field-group-title { display: flex; align-items: center; gap: 8px; }
|
||||
.tile-settings .field-group-name { flex: 1; min-width: 0; }
|
||||
.tile-settings .group-toggle { margin: 0 0 0 auto; }
|
||||
.tile-settings .group-toggle .check-label { display: none; }
|
||||
.tile-settings .field-group.is-off .field-group-body { opacity: 0.45; }
|
||||
/* several picks from a list (type `multiselect`) */
|
||||
.tile-settings .multiselect-field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.tile-settings .multiselect { display: flex; flex-direction: column; gap: 3px; }
|
||||
.tile-settings .multiselect.is-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
|
||||
gap: 3px 14px;
|
||||
}
|
||||
.tile-settings .multiselect-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.tile-settings .multiselect-row .check-label { flex: 1 1 0; min-width: 0; }
|
||||
.tile-settings .multiselect-empty { color: var(--muted); font-size: 12px; }
|
||||
/* a text built from offered variables (type `template`) */
|
||||
.tile-settings .template-vars { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 2px; }
|
||||
.tile-settings .template-var {
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
cursor: grab;
|
||||
}
|
||||
.tile-settings .template-var:hover { border-color: var(--accent); }
|
||||
.tile-settings .template-var:active { cursor: grabbing; }
|
||||
|
||||
/* A popover with grouped settings needs room for its columns; it still
|
||||
never grows past the tile (max-width above). */
|
||||
.tile-settings.has-groups {
|
||||
min-width: min(540px, calc(100% - 12px));
|
||||
min-width: min(540px, calc(100vw - 16px));
|
||||
container-type: inline-size;
|
||||
}
|
||||
.tile-settings .field-group {
|
||||
@@ -593,7 +676,7 @@ body.fullscreen #grid { padding-bottom: 0; }
|
||||
.tile-settings .field-group .field:last-child { margin-bottom: 0; }
|
||||
/* Settings apply as they change, so nothing sits under the last field — its
|
||||
bottom margin would only pad the popover's own padding a second time. */
|
||||
.tile-settings > :last-child { margin-bottom: 0; }
|
||||
.tile-settings-body > :last-child { margin-bottom: 0; }
|
||||
.tile-settings .field-group-body.is-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
|
||||
|
||||
@@ -466,6 +466,11 @@ function sanitizeTileEntry(raw) {
|
||||
// at most one tile of this entry per dashboard (the picker greys it out)
|
||||
single: raw.single === true,
|
||||
};
|
||||
// An entry may offer its own gear settings — a solo timer wants none of a
|
||||
// full tile's switches. Same shape as the manifest's instanceSchema/Groups.
|
||||
for (const key of ['instanceSchema', 'instanceGroups']) {
|
||||
if (raw[key] && typeof raw[key] === 'object') out[key] = raw[key];
|
||||
}
|
||||
for (const key of ['minSize', 'defaultSize']) {
|
||||
const size = raw[key];
|
||||
if (size && Number(size.w) > 0 && Number(size.h) > 0) {
|
||||
|
||||
Reference in New Issue
Block a user