diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index c2da220..d6cd507 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -106,6 +106,12 @@ the group name: placeholders, where a value comes from. Keep labels short and put the 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: ""` hides a field until the boolean/`switch` field named `` 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. diff --git a/public/shell.js b/public/shell.js index 3936ede..0ae98f3 100644 --- a/public/shell.js +++ b/public/shell.js @@ -934,11 +934,54 @@ function toggleSettingsPopover(tile, el) { pop.className = 'tile-settings'; 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)) { const type = spec?.type || 'string'; const label = spec?.label || key; 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'); field.className = 'field' + (type === 'boolean' ? ' check' : ''); @@ -970,7 +1013,13 @@ function toggleSettingsPopover(tile, el) { field.append(span, input); 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'); diff --git a/public/style.css b/public/style.css index 77a5b71..e5f52b9 100644 --- a/public/style.css +++ b/public/style.css @@ -529,6 +529,70 @@ body.fullscreen #grid { padding-bottom: 0; } 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 { display: flex; flex-direction: column; diff --git a/server.js b/server.js index 548729d..60bcc1b 100644 --- a/server.js +++ b/server.js @@ -402,6 +402,7 @@ async function clientManifest(id) { minSize: man.minSize || { w: 1, h: 1 }, defaultSize: man.defaultSize || { w: 4, h: 3 }, instanceSchema: man.instanceSchema || {}, + instanceGroups: man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {}, hasServer: Boolean(man.server), config: clientConfig(id), tiles: await moduleTiles(id),