Shell: grouped, collapsible, multi-column per-tile settings popover
The gear popover learns the admin form's conventions: an instanceSchema
field's `group` starts a labeled subsection, `help` puts a line of guidance
under a control, and a manifest `instanceGroups[name]` adds
{ collapsed, columns, help } for that group (rendered as <details> with a
grid body; container queries drop to two or one column in narrow tiles).
/api/modules now exposes instanceGroups. Lets a module keep a long
show/hide checklist per tile — where display choices belong — without
the popover turning into a scroll of thirty loose checkboxes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
20dc2deb2b
commit
01eac1bdc4
@@ -106,6 +106,12 @@ the group name:
|
|||||||
placeholders, where a value comes from. Keep labels short and put the
|
placeholders, where a value comes from. Keep labels short and put the
|
||||||
explanation there.
|
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.
|
||||||
|
|
||||||
`showWhen: "<key>"` hides a field until the boolean/`switch` field named
|
`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
|
`<key>` is on — e.g. the audio device and channel appear only once the LTC
|
||||||
listener switch is enabled. The hidden field still keeps its stored value.
|
listener switch is enabled. The hidden field still keeps its stored value.
|
||||||
|
|||||||
+50
-1
@@ -934,11 +934,54 @@ function toggleSettingsPopover(tile, el) {
|
|||||||
pop.className = 'tile-settings';
|
pop.className = 'tile-settings';
|
||||||
const inputs = new Map();
|
const inputs = new Map();
|
||||||
|
|
||||||
|
// 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 : {};
|
||||||
|
let groupName = null;
|
||||||
|
let container = pop;
|
||||||
|
|
||||||
for (const [key, spec] of Object.entries(man.instanceSchema)) {
|
for (const [key, spec] of Object.entries(man.instanceSchema)) {
|
||||||
const type = spec?.type || 'string';
|
const type = spec?.type || 'string';
|
||||||
const label = spec?.label || key;
|
const label = spec?.label || key;
|
||||||
const current = key in (tile.settings || {}) ? tile.settings[key] : spec?.default;
|
const current = key in (tile.settings || {}) ? tile.settings[key] : spec?.default;
|
||||||
|
|
||||||
|
const g = spec?.group || '';
|
||||||
|
if (g !== groupName) {
|
||||||
|
groupName = g;
|
||||||
|
if (g) {
|
||||||
|
const meta = groupMeta[g] && typeof groupMeta[g] === 'object' ? groupMeta[g] : {};
|
||||||
|
const folds = meta.collapsed !== undefined;
|
||||||
|
const group = document.createElement(folds ? 'details' : 'div');
|
||||||
|
group.className = 'field-group';
|
||||||
|
if (folds) group.open = !meta.collapsed;
|
||||||
|
const title = document.createElement(folds ? 'summary' : 'div');
|
||||||
|
title.className = 'field-group-title';
|
||||||
|
title.textContent = g;
|
||||||
|
group.appendChild(title);
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.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));
|
||||||
|
}
|
||||||
|
if (meta.help) {
|
||||||
|
const help = document.createElement('div');
|
||||||
|
help.className = 'field-group-help';
|
||||||
|
help.textContent = meta.help;
|
||||||
|
body.appendChild(help);
|
||||||
|
}
|
||||||
|
group.appendChild(body);
|
||||||
|
pop.appendChild(group);
|
||||||
|
pop.classList.add('has-groups');
|
||||||
|
container = body;
|
||||||
|
} else {
|
||||||
|
container = pop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const field = document.createElement('label');
|
const field = document.createElement('label');
|
||||||
field.className = 'field' + (type === 'boolean' ? ' check' : '');
|
field.className = 'field' + (type === 'boolean' ? ' check' : '');
|
||||||
|
|
||||||
@@ -970,7 +1013,13 @@ function toggleSettingsPopover(tile, el) {
|
|||||||
field.append(span, input);
|
field.append(span, input);
|
||||||
inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value));
|
inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value));
|
||||||
}
|
}
|
||||||
pop.appendChild(field);
|
if (spec?.help) {
|
||||||
|
const help = document.createElement('small');
|
||||||
|
help.className = 'field-help';
|
||||||
|
help.textContent = spec.help;
|
||||||
|
field.appendChild(help);
|
||||||
|
}
|
||||||
|
container.appendChild(field);
|
||||||
}
|
}
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
|
|||||||
@@ -529,6 +529,70 @@ body.fullscreen #grid { padding-bottom: 0; }
|
|||||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
|
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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));
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
.tile-settings .field-group {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.tile-settings .field-group-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.tile-settings details.field-group > summary.field-group-title {
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.tile-settings details.field-group > summary::-webkit-details-marker { display: none; }
|
||||||
|
.tile-settings details.field-group > summary.field-group-title::before {
|
||||||
|
content: "";
|
||||||
|
width: 0; height: 0;
|
||||||
|
border-left: 5px solid var(--muted);
|
||||||
|
border-top: 4px solid transparent;
|
||||||
|
border-bottom: 4px solid transparent;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.tile-settings details.field-group[open] > summary.field-group-title::before { transform: rotate(90deg); }
|
||||||
|
.tile-settings details.field-group[open] > summary.field-group-title { margin-bottom: 8px; }
|
||||||
|
.tile-settings .field-group .field:last-child { margin-bottom: 0; }
|
||||||
|
.tile-settings .field-group-body.is-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
|
||||||
|
gap: 3px 14px;
|
||||||
|
}
|
||||||
|
.tile-settings .field-group-body.is-columns .field { margin-bottom: 3px; }
|
||||||
|
.tile-settings .field-group-body.is-columns .field-group-help { grid-column: 1 / -1; }
|
||||||
|
@container (max-width: 420px) {
|
||||||
|
.tile-settings .field-group-body.is-columns { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
@container (max-width: 260px) {
|
||||||
|
.tile-settings .field-group-body.is-columns { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
.tile-settings .field-group-help,
|
||||||
|
.tile-settings .field-help {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.tile-settings .field-group-help { margin: -2px 0 6px; }
|
||||||
|
.tile-settings .field-help { margin-top: 1px; }
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -402,6 +402,7 @@ async function clientManifest(id) {
|
|||||||
minSize: man.minSize || { w: 1, h: 1 },
|
minSize: man.minSize || { w: 1, h: 1 },
|
||||||
defaultSize: man.defaultSize || { w: 4, h: 3 },
|
defaultSize: man.defaultSize || { w: 4, h: 3 },
|
||||||
instanceSchema: man.instanceSchema || {},
|
instanceSchema: man.instanceSchema || {},
|
||||||
|
instanceGroups: man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {},
|
||||||
hasServer: Boolean(man.server),
|
hasServer: Boolean(man.server),
|
||||||
config: clientConfig(id),
|
config: clientConfig(id),
|
||||||
tiles: await moduleTiles(id),
|
tiles: await moduleTiles(id),
|
||||||
|
|||||||
Reference in New Issue
Block a user