diff --git a/public/js/admin-app-form.js b/public/js/admin-app-form.js index aa3d636..4a3985d 100644 --- a/public/js/admin-app-form.js +++ b/public/js/admin-app-form.js @@ -1,7 +1,7 @@ -import { clr as rc, el, inp as inpById, q as qSel, qa, qi, tgt } from '/js/utils.js?v=970a91b0'; +import { clr as rc, el, inp as inpById, q as qSel, qa, qi, tgt } from '/js/utils.js?v=ada0c382'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { loadLocalIcons, resolveIcon, iconChain, cdnIconRef, splitIconRef } from '/js/icons.js?v=04e7796e'; -import { state } from '/js/admin-state.js?v=7d68e98e'; +import { loadLocalIcons, resolveIcon, iconChain, cdnIconRef, splitIconRef } from '/js/icons.js?v=9c8c550c'; +import { state } from '/js/admin-state.js?v=5a5d655f'; import { isDockBlocked, DOCK_MAX, @@ -10,20 +10,21 @@ import { failureIsMissingApiPath, nextActiveIndex, sameIconName, -} from '/js/admin-logic.js?v=74cb4272'; +} from '/js/admin-logic.js?v=69e57d35'; import { t } from '/js/i18n.js?v=e644a5c5'; import { toast, ag, ap, PE_SVG, - CHEV_SVG, initInlineEdit, + reveal, setTogDisabled, - wireChecklist, -} from '/js/admin-shared.js?v=0f36d0dc'; + swapContent, +} from '/js/admin-shared.js?v=ca64cc9c'; +import { createListbox } from '/js/listbox.js?v=3e267705'; import { MAX_LABELS } from '/js/badge-logic.js?v=b3c8b6c2'; -import { renderColorControl, BADGE_SWATCHES, BADGE_DEFAULT } from '/js/admin-color-control.js?v=d162431d'; +import { renderColorControl, BADGE_SWATCHES, BADGE_DEFAULT } from '/js/admin-color-control.js?v=cfb3b3e9'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; @@ -37,13 +38,6 @@ export function buildFolderForm(body, item) { } }); - const opts = apps.length - ? apps.map( - a => - html`
  • ${a.label || a.id}
  • `, - ) - : html`
  • ${t('folder.noApps')}
  • `; - setHtml( body, html` @@ -54,44 +48,35 @@ export function buildFolderForm(body, item) { -
    +
    ${t('folder.addApps')} -
    - - -

    ${t('folder.tip')}

    `, ); initInlineEdit('ie-fname', 'f-fname', { placeholder: t('folder.namePh') }); - _wireFolderApps(); -} - -function _wireFolderApps() { - const dd = el('folder-apps-dd'); - const btn = inpById('folder-apps-btn'); - const list = el('folder-apps-list'); - const label = el('folder-apps-label'); - if (!dd || !btn || !list || !label) return; - const sync = () => { - const sel = qa('li[aria-selected="true"]', list); - label.textContent = + _wireFolderApps(apps, children); +} + +function _wireFolderApps(apps, children) { + const row = el('folder-apps-row'); + if (!row) return; + const box = createListbox({ + label: t('folder.appsInFolder'), + multiple: true, + options: apps.map(a => ({ value: a.id, label: a.label || a.id })), + value: children, + emptyText: t('folder.noApps'), + summary: sel => sel.length === 0 ? t('folder.selectApps') : sel.length === 1 - ? sel[0].textContent - : t('widgetCfg.selectedCount', { count: sel.length }); - }; - wireChecklist(dd, btn, list, li => { - li.setAttribute('aria-selected', li.getAttribute('aria-selected') === 'true' ? 'false' : 'true'); - sync(); + ? sel[0].label + : t('widgetCfg.selectedCount', { count: sel.length }), }); - sync(); + state._folderApps = box; + row.appendChild(box.el); } export function buildAppForm(body, item) { @@ -152,7 +137,7 @@ export function buildAppForm(body, item) {

    ${t('app.badge')}

    ${t('app.healthCheck')}${tog('hc-en', hc.enabled, t('app.healthCheck'))}
    -
    +
    ${t('app.type')}
    @@ -162,34 +147,34 @@ export function buildAppForm(body, item) { ${ier('ie-hc-ping', t('app.pingUrl'), 'hc-ping', hc.pingUrl, t('app.pingUrlPh'), 'url')}
    -
    +
    ${globalHealthOn ? '' : html`

    ${t('app.healthGlobalOff')}

    `}
    ${t('app.fixedLabel')}${tog('static-en', hasStatic, t('app.fixedLabel'))}
    -
    +
    ${ier('ie-static-label', t('app.labelText'), 'f-static-label', staticBadge.label, t('app.labelPh'))}
    -
    +
    ${t('app.liveActivity')}${tog('act-en', act.enabled, t('app.liveActivity'))}
    -
    +
    ${ier('ie-burl', t('app.apiUrl'), 'f-burl', act.url, t('app.apiUrlPh'), 'url')}
    ${t('app.authentication')}${tog('auth-en', !!(act.params || act.headers), t('app.authentication'))}
    -
    +
    ${t('app.addToUrl')} (query params)
    ${t('app.addToHeader')}
    -
    +
    ${t('app.poll')}
    ${pollBefore}${pollAfter}
    -
    +
    @@ -252,7 +237,9 @@ export function buildAppForm(body, item) { const hcEn = el('hc-en'); const showHide = (id, on) => { const node = el(id); - if (node) node.hidden = !on; + if (!node) return; + if (node.classList.contains('reveal')) reveal(node, on); + else node.hidden = !on; }; hcEn?.addEventListener('change', e => { showHide('hc-sub', tgt(e).checked); @@ -260,8 +247,10 @@ export function buildAppForm(body, item) { document.querySelectorAll('input[name="hc-type"]').forEach(r => r.addEventListener('change', () => { const ping = inpById('hc-type-ping')?.checked; - showHide('hc-con-row', !ping); - showHide('hc-ping-row', ping); + swapContent(el('hc-con-row')?.parentElement, () => { + showHide('hc-con-row', !ping); + showHide('hc-ping-row', ping); + }); }), ); el('hc-ping-test')?.addEventListener('click', testPing); @@ -351,19 +340,19 @@ function actValueOptions() { return out; } -function _valueSelect(idx, path) { +function _valueSelectOptions() { const opts = actValueOptions(); const groups = [ { name: t('app.values'), rows: opts.filter(o => !o.computed) }, { name: t('app.computedFromArray'), rows: opts.filter(o => o.computed) }, ].filter(g => g.rows.length); - const optionOf = o => - html``; - const body = - groups.length > 1 - ? groups.map(g => html`${g.rows.map(optionOf)}`) - : opts.map(optionOf); - return html`
    ${t('app.value')}
    ${raw(CHEV_SVG)}
    `; + const entryOf = o => ({ value: o.path, label: o.value == null ? o.label : `${o.label} \u2014 ${o.value}` }); + if (groups.length < 2) return opts.map(entryOf); + return groups.flatMap(g => [{ group: g.name }, ...g.rows.map(entryOf)]); +} + +function _valueSelect(idx) { + return html`
    ${t('app.value')}
    `; } function renderActLabels(host) { @@ -405,7 +394,7 @@ function renderActLabels(host) { card.className = 'grp'; setHtml( card, - html`${_valueSelect(i, path)} + html`${_valueSelect(i)} ${_ieRow(`ie-albl-name-${i}`, _optRow(t('app.labelText')), `albl-name-${i}`, l.name, t('app.labelPh'))}
    ${_ieRow(`ie-albl-unit-${i}`, _optRow(t('app.unit')), `albl-unit-${i}`, l.unit, t('app.unitPh'))} @@ -421,20 +410,26 @@ function renderActLabels(host) { swatchColors: BADGE_SWATCHES, label: t('common.color'), }); - const sel = inpById(`albl-path-${i}`); - if (sel) { - sel.onchange = () => { - captureActLabels(); - const next = sel.value; - if (state.spaths.includes(next)) { - sel.value = state.spaths[i]; - toast(t('app.labelValueTaken'), 'err'); - return; - } - repathActLabel(state.spaths[i], next); - state.spaths[i] = next; - syncActMode(); - }; + const pathRow = el(`albl-path-row-${i}`); + if (pathRow) { + const box = createListbox({ + id: `albl-path-${i}`, + label: t('app.value'), + options: _valueSelectOptions(), + value: path, + onChange: next => { + captureActLabels(); + if (state.spaths.includes(String(next))) { + box.setValue(state.spaths[i]); + toast(t('app.labelValueTaken'), 'err'); + return; + } + repathActLabel(state.spaths[i], String(next)); + state.spaths[i] = String(next); + syncActMode(); + }, + }); + pathRow.appendChild(box.el); } }); wireActLabelDrag(host); diff --git a/public/js/admin-auth.js b/public/js/admin-auth.js index 8baf8ca..b42ba82 100644 --- a/public/js/admin-auth.js +++ b/public/js/admin-auth.js @@ -1,7 +1,7 @@ -import { ag, ap } from '/js/admin-shared.js?v=0f36d0dc'; +import { ag, ap } from '/js/admin-shared.js?v=ca64cc9c'; import { t } from '/js/i18n.js?v=e644a5c5'; import { pwStrength } from '/js/password-strength.js?v=42f45ac7'; -import { el, inp as inpById, qa } from '/js/utils.js?v=970a91b0'; +import { el, inp as inpById, qa } from '/js/utils.js?v=ada0c382'; export async function checkAuth(onLogin) { try { diff --git a/public/js/admin-color-control.js b/public/js/admin-color-control.js index 6606422..de58183 100644 --- a/public/js/admin-color-control.js +++ b/public/js/admin-color-control.js @@ -1,7 +1,7 @@ -import { PE_SVG, initInlineEdit, toast } from '/js/admin-shared.js?v=0f36d0dc'; +import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=ca64cc9c'; import { t } from '/js/i18n.js?v=e644a5c5'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { qa, q } from '/js/utils.js?v=970a91b0'; +import { qa, q } from '/js/utils.js?v=ada0c382'; const CC_SWATCHES = ['#1c1c1e', '#8e8e93', '#f2f2f7', '#ff393c', '#ffcd00', '#35c759', '#0289ff', '#cb30df']; export const BADGE_DEFAULT = '#1e6ef4'; @@ -134,15 +134,17 @@ export function renderColorControl( ${swatchColors.map(swatch)}`; const wrap = document.createElement('div'); const slider = (label, cls, id, max, val, lo, hi) => html` -
    ${label}
    ${raw(lo)}${raw(hi)}
    `; +
    ${label}
    ${raw(lo)}${raw(hi)}
    `; setHtml( wrap, html`
    ${label}
    ${swatches}
    +
    ${slider('Hue', 'hsb-range hsb-hue', `${idPrefix}-h`, 360, init.h, _ccIco.hueLo, _ccIco.hueHi)} ${slider('Saturation', 'hsb-range', `${idPrefix}-s`, 100, init.s, _ccIco.satLo, _ccIco.satHi)} ${slider('Brightness', 'hsb-range', `${idPrefix}-v`, 100, init.v, _ccIco.brLo, _ccIco.brHi)} -
    ${t('appearance.colorCode')}#rrggbb or any CSS color
    `, +
    ${t('appearance.colorCode')}#rrggbb or any CSS color
    +
    `, ); /* The markup carries no style attribute, so the page keeps style-src without 'unsafe-inline'. A swatch paints itself from its own value. */ @@ -157,13 +159,15 @@ export function renderColorControl( sEl = qLocal(`#${idPrefix}-s`), vEl = qLocal(`#${idPrefix}-v`); const codeRv = qLocal(`#${idPrefix}-code-row .rv`); - const tune = rows.filter(r => r.classList.contains('cc-tune')); + const tune = rows.find(r => r.classList.contains('cc-tune')); const hidden = document.createElement('input'); hidden.type = 'hidden'; hidden.id = `${idPrefix}-val`; container.appendChild(hidden); let mode = isSem(value) ? value : 'color'; let showTune = false; + /* The first paint restores stored state, so it must not animate. */ + let _painted = false; /* The value as it was stored. The sliders are integers, so reading a colour back out of them shifts it, and an untouched control would save a colour nobody picked. Cleared the moment the user changes anything. */ @@ -200,13 +204,14 @@ export function renderColorControl( }); const rb = q('.cc-rainbow', container); if (rb) rb.classList.toggle('on', mode === 'color' && showTune); - tune.forEach(r => r.classList.toggle('d-none', !showTune)); + reveal(tune, showTune, !_painted); if (!codeRv.closest('.editing')) { codeRv.textContent = mode === 'color' ? hex : t(mode === 'dark' ? 'appearance.displayDark' : 'appearance.displayLight'); codeRv.classList.remove('is-ph'); } hidden.value = mode === 'color' ? (pristine ?? hex) : mode; + _painted = true; } const commit = () => { pristine = null; diff --git a/public/js/admin-drag.js b/public/js/admin-drag.js index 8de314b..05e79cb 100644 --- a/public/js/admin-drag.js +++ b/public/js/admin-drag.js @@ -10,9 +10,9 @@ it is near, and which classes say so. */ import { applyDrop, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=6b767e76'; -import { state } from '/js/admin-state.js?v=7d68e98e'; +import { state } from '/js/admin-state.js?v=5a5d655f'; import { snapshotItems } from '/js/admin-save-logic.js?v=4f71ef6c'; -import { qa } from '/js/utils.js?v=970a91b0'; +import { qa } from '/js/utils.js?v=ada0c382'; /** @type {(before: unknown) => void} */ let _save = () => {}; diff --git a/public/js/admin-list.js b/public/js/admin-list.js index eff0d49..49029ee 100644 --- a/public/js/admin-list.js +++ b/public/js/admin-list.js @@ -11,15 +11,15 @@ The filter and the collapsed folders are in admin-state.js, because the page reads and writes them too. */ -import { collapsedFolders, filter, state } from '/js/admin-state.js?v=7d68e98e'; +import { collapsedFolders, filter, state } from '/js/admin-state.js?v=5a5d655f'; import { snapshotItems } from '/js/admin-save-logic.js?v=4f71ef6c'; -import { reorderItems } from '/js/admin-logic.js?v=74cb4272'; -import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=3ffdf185'; -import { paintIcon } from '/js/admin-shared.js?v=0f36d0dc'; -import { clr as rc, el, qa, setUserText } from '/js/utils.js?v=970a91b0'; +import { reorderItems } from '/js/admin-logic.js?v=69e57d35'; +import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=e514303e'; +import { paintIcon } from '/js/admin-shared.js?v=ca64cc9c'; +import { clr as rc, el, qa, setUserText } from '/js/utils.js?v=ada0c382'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { t } from '/js/i18n.js?v=e644a5c5'; -import { sizeLabel } from '/js/admin-widget-form.js?v=0cb044d0'; +import { sizeLabel } from '/js/admin-widget-form.js?v=20f4cf98'; import { widgetGlyph } from '/js/widget-glyphs.js?v=12b0a947'; /** @type {{ openModal: (idx: number|null) => void, diff --git a/public/js/admin-logic.js b/public/js/admin-logic.js index 780fc25..a54df69 100644 --- a/public/js/admin-logic.js +++ b/public/js/admin-logic.js @@ -314,3 +314,17 @@ export function failureIsMissingApiPath(url, advice) { if (advice && advice.sessionExpired) return false; return isBareHostUrl(url); } + +/** Compares what `read` returns now with what it returned at the last reset. + + @param {() => string} read + @returns {{ dirty: () => boolean, reset: () => void }} */ +export function createDirtyTracker(read) { + let base = read(); + return { + dirty: () => read() !== base, + reset: () => { + base = read(); + }, + }; +} diff --git a/public/js/admin-settings.js b/public/js/admin-settings.js index 4b452c1..92cb8c0 100644 --- a/public/js/admin-settings.js +++ b/public/js/admin-settings.js @@ -1,14 +1,90 @@ -import { toast, ag, ap } from '/js/admin-shared.js?v=0f36d0dc'; +import { toast, ag, ap, reveal, swapContent } from '/js/admin-shared.js?v=ca64cc9c'; import { pwStrength } from '/js/password-strength.js?v=42f45ac7'; import { t } from '/js/i18n.js?v=e644a5c5'; -import { shouldWritePassword, settingsSaveBlocker, clearsStoredPassword, BLOCK } from '/js/admin-logic.js?v=74cb4272'; +import { + shouldWritePassword, + settingsSaveBlocker, + clearsStoredPassword, + createDirtyTracker, + BLOCK, +} from '/js/admin-logic.js?v=69e57d35'; import { confirmText } from '/js/modal.js?v=11fa1eff'; -import { el, inp, q, qa, setUserText } from '/js/utils.js?v=970a91b0'; +import { el, inp, setUserText } from '/js/utils.js?v=ada0c382'; /* Mirrors the server's rule: auth cannot be switched on with no password. */ let _passwordSet = false; let _authEnabled = false; +/** @type {{ dirty: () => boolean, reset: () => void } | null} */ +let _srvTrack = null; +/** @type {{ dirty: () => boolean, reset: () => void } | null} */ +let _bgTrack = null; + +const _val = (...ids) => { + for (const id of ids) { + const node = inp(id); + if (node) return node.type === 'checkbox' ? String(node.checked) : node.value; + } + return ''; +}; +const _shown = id => { + const node = el(id); + return node && !node.classList.contains('is-ph') ? node.textContent : ''; +}; + +/* Only what the header Save writes. The switches that save on change are left + out. */ +const readServerForm = () => + JSON.stringify([ + _shown('ie-title-v'), + _shown('ie-desc-v'), + _val('srv-ip'), + _val('srv-docker-en'), + _val('srv-socket'), + _val('srv-hide-healthy'), + _val('log-level'), + _val('lang-sel'), + _val('sec-en'), + _val('sec-pw'), + ]); +const readWallpaperForm = () => + JSON.stringify([ + _val('bg-type'), + _val('bg-br'), + _val('bg-col-inp', 'bg-col'), + _val('bg-url-inp', 'bg-url'), + _val('bg-fit'), + _val('bg-color-inp', 'bg-color'), + _val('bg-apikey-inp', 'bg-apikey'), + ]); + +/** Disables `buttonId` while its section matches what was last saved. */ +function trackSave(buttonId, read) { + const btn = /** @type {HTMLButtonElement|null} */ (el(buttonId)); + const tr = createDirtyTracker(read); + const sync = () => { + if (btn) btn.disabled = !tr.dirty(); + }; + /* On the document, not the section: a picker's option list is attached to + the body, and a choice made there must still enable Save. Deferred: pickers + and inline editors update their value after the event. */ + for (const type of ['input', 'change', 'click', 'keyup', 'focusout']) + document.addEventListener(type, () => setTimeout(sync)); + sync(); + return { + dirty: tr.dirty, + reset: () => { + tr.reset(); + sync(); + }, + }; +} + +/** Whether General or Appearance holds edits their Save has not written. */ +export function settingsDirty() { + return !!(_srvTrack?.dirty() || _bgTrack?.dirty()); +} + /* Hint codes from the socket proxy probe. The server picks the code from the address shape; the wording lives here so it is translated. */ const SOCKET_HINTS = Object.freeze({ @@ -18,13 +94,12 @@ const SOCKET_HINTS = Object.freeze({ /* Both controls only mean anything while auth is on. Revoke also needs a stored password, which is what makes a session possible. */ -function syncSessionRows() { +function syncSessionRows(now = false) { const on = !!inp('sec-en')?.checked; el('sec-logout')?.classList.toggle('d-none', !on); const canRevoke = on && _passwordSet; - el('sec-revoke-row')?.classList.toggle('d-none', !canRevoke); - const revokeTip = el('revoke-tip'); - if (revokeTip) revokeTip.classList.toggle('d-none', !canRevoke); + reveal(el('sec-revoke-wrap'), canRevoke, now); + reveal(el('revoke-tip-wrap'), canRevoke, now); } export function loadSettings(c) { @@ -49,46 +124,11 @@ export function loadSettings(c) { if (typeEl) { typeEl.value = bg.type || 'unsplash'; showBgFields(bg.type || 'unsplash'); - const btn = el('bg-type-btn'); - const labels = { - unsplash: t('appearance.sourceUnsplash'), - url: t('appearance.sourceUrl'), - color: t('appearance.sourceColor'), - }; - if (btn) { - const tn = btn.childNodes[0]; - if (tn && tn.nodeType === 3) tn.textContent = labels[typeEl.value] || typeEl.value; - } - qa('#bg-type-list li', document).forEach(li => - li.setAttribute('aria-selected', String(li.dataset.val === typeEl.value)), - ); } const llEl = inp('log-level'); - if (llEl) { - llEl.value = s.logLevel || 'info'; - const llBtn = el('log-level-btn'); - const llLabels = { debug: t('general.logDebug'), info: t('general.logInfo'), error: t('general.logError') }; - if (llBtn) { - const tn = llBtn.childNodes[0]; - if (tn && tn.nodeType === 3) tn.textContent = llLabels[llEl.value] || llEl.value; - } - qa('#log-level-list li', document).forEach(li => - li.setAttribute('aria-selected', String(li.dataset.val === llEl.value)), - ); - } + if (llEl) llEl.value = s.logLevel || 'info'; const langEl = inp('lang-sel'); - if (langEl) { - langEl.value = s.language || 'en'; - const laBtn = el('lang-btn'); - const laLi = q(`#lang-list li[data-val="${langEl.value}"]`); - if (laBtn) { - const tn = laBtn.childNodes[0]; - if (tn && tn.nodeType === 3) tn.textContent = laLi ? laLi.textContent : langEl.value; - } - qa('#lang-list li', document).forEach(li => - li.setAttribute('aria-selected', String(li.dataset.val === langEl.value)), - ); - } + if (langEl) langEl.value = s.language || 'en'; /* The key itself is never included in /api/config. */ const apiEl = inp('bg-apikey-inp') || inp('bg-apikey'); if (apiEl) { @@ -164,7 +204,6 @@ export function loadSettings(c) { _si('bg-col-inp', s.background?.collection || ''); _si('bg-url-inp', s.background?.url || ''); _si('bg-fit', s.background?.fit === 'fit' ? 'fit' : 'fill'); - showBgFit(s.background?.fit === 'fit' ? 'fit' : 'fill'); showWallpaperFile(s.background?.url || ''); _si('bg-color-inp', s.background?.color || ''); _sv('ie-bgurl-v', s.background?.url, 'Image URL'); @@ -175,19 +214,15 @@ export function loadSettings(c) { const dockerEnEl = inp('srv-docker-en'); const dockerSubEl = el('srv-docker-sub'); const socketEl = inp('srv-socket'); - const hideHealthyRowEl = el('srv-hide-healthy-row'); const hideHealthyEl = inp('srv-hide-healthy'); if (dockerEnEl) { dockerEnEl.checked = !!s.server?.socketProxyUrl; - const applyDocker = v => { + const applyDocker = (v, now = false) => { if (dockerSubEl) dockerSubEl.classList.toggle('open', v); - if (hideHealthyRowEl) hideHealthyRowEl.classList.toggle('d-none', !v); - const socketRow = el('ie-socket'); - if (socketRow) socketRow.classList.toggle('d-none', !v); - const socketHint = el('socket-hint'); - if (socketHint) socketHint.classList.toggle('d-none', !v); + reveal(el('srv-docker-rows'), v, now); + reveal(el('socket-hint-wrap'), v, now); }; - applyDocker(dockerEnEl.checked); + applyDocker(dockerEnEl.checked, true); dockerEnEl.addEventListener('change', () => applyDocker(dockerEnEl.checked)); } if (hideHealthyEl) hideHealthyEl.checked = s.server?.hideHealthyBadge !== false; @@ -220,9 +255,11 @@ export function loadSettings(c) { secRevoke.disabled = false; } }); - secEnEl?.addEventListener('change', syncSessionRows); + secEnEl?.addEventListener('change', () => syncSessionRows()); - syncAuthFromServer(); + _srvTrack = trackSave('srv-save', readServerForm); + _bgTrack = trackSave('bg-save', readWallpaperForm); + syncAuthFromServer().then(() => _srvTrack?.reset()); } async function syncAuthFromServer() { @@ -238,14 +275,12 @@ async function syncAuthFromServer() { if (secEnEl) { /* The effective state. Enabled with no password behaves as off. */ secEnEl.checked = !!d.enabled; - const pwRow = el('ie-pw'); - const pwHint = el('pw-hint-static'); - if (pwRow) pwRow.classList.toggle('d-none', !d.enabled); - if (pwHint) pwHint.classList.toggle('d-none', !d.enabled); + reveal(el('ie-pw-wrap'), !!d.enabled, true); + reveal(el('pw-hint-wrap'), !!d.enabled, true); } const pwValEl = el('ie-pw-v'); if (pwValEl) pwValEl.textContent = d.passwordSet ? t('common.configured') : t('common.notSet'); - syncSessionRows(); + syncSessionRows(true); } /** The stored wallpaper, named by its file rather than its full path. @@ -263,21 +298,16 @@ export function showWallpaperFile(url) { } } -/** @param {string} fit @returns {void} */ -export function showBgFit(fit) { - const btn = el('bg-fit-btn'); - const tn = btn?.childNodes[0]; - if (tn && tn.nodeType === 3) tn.textContent = fit === 'fit' ? t('appearance.fitContain') : t('appearance.fitFill'); - qa('#bg-fit-list li', document).forEach(li => li.setAttribute('aria-selected', String(li.dataset.val === fit))); -} - export function showBgFields(type) { - ['unsplash', 'url', 'color'].forEach(t => { - const node = el(`bg-${t}-fields`); - if (node) node.classList.toggle('d-none', t !== type); + const host = el('bg-unsplash-fields')?.parentElement; + swapContent(host, () => { + ['unsplash', 'url', 'color'].forEach(t => { + const node = el(`bg-${t}-fields`); + if (node) node.classList.toggle('d-none', t !== type); + }); + const brRow = el('bg-brightness-row'); + if (brRow) brRow.classList.toggle('d-none', type === 'color'); }); - const brRow = el('bg-brightness-row'); - if (brRow) brRow.classList.toggle('d-none', type === 'color'); } /** @param {Event} [e] */ async function saveLabels(e) { @@ -335,6 +365,7 @@ async function saveWallpaper() { const keyVal = (inp('bg-apikey-inp') || inp('bg-apikey'))?.value?.trim() || ''; if (keyVal) await ap('/api/settings/unsplash-key', { apiKey: keyVal }); } + _bgTrack?.reset(); toast(t('toast.saved')); } catch (e) { toast(t('toast.saveFailed', { err: e.message }), 'err'); @@ -444,6 +475,7 @@ async function saveServer() { pwEl.value = ''; } } + _srvTrack?.reset(); toast(socketWarning || t('toast.saved'), socketWarning ? 'err' : 'ok'); if (langChanged) { location.reload(); diff --git a/public/js/admin-shared.js b/public/js/admin-shared.js index d158cb8..28d093f 100644 --- a/public/js/admin-shared.js +++ b/public/js/admin-shared.js @@ -1,9 +1,8 @@ /* Stateless helpers shared by the admin modules. Mutable state stays out. */ -import { nextActiveIndex, recoversSession, toastHoldMs } from '/js/admin-logic.js?v=74cb4272'; -import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; -import { el, qa, q } from '/js/utils.js?v=970a91b0'; +import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=69e57d35'; +import { el, q } from '/js/utils.js?v=ada0c382'; import { t } from '/js/i18n.js?v=e644a5c5'; -import { iconChain } from '/js/icons.js?v=04e7796e'; +import { iconChain } from '/js/icons.js?v=9c8c550c'; export const API = ''; @@ -124,12 +123,85 @@ export function setTogDisabled(input, disabled, describedById) { }); } +/** Opens or closes a `.reveal` wrapper. + + @param {Element|null} node @param {boolean} open + @param {boolean} [now] Skip the animation, for state restored from the + server rather than chosen by the user. + @returns {void} */ +export function reveal(node, open, now = false) { + if (!node) return; + const box = /** @type {HTMLElement} */ (node); + if (box.classList.contains('open') === open) return; + /* The clip is only for the animation. Left on it cuts off any menu opened + from a revealed row. */ + box.classList.remove('reveal-done'); + clearTimeout(Number(box.dataset.revealTimer || 0)); + if (open) { + const settle = () => box.classList.add('reveal-done'); + if (now) settle(); + else box.dataset.revealTimer = String(setTimeout(settle, 400)); + } + if (now) { + box.classList.add('reveal-now'); + box.classList.toggle('open', open); + /* Read back, or both class changes land in one recalculation and it + animates anyway. */ + void box.offsetHeight; + box.classList.remove('reveal-now'); + return; + } + box.classList.toggle('open', open); +} + +/** Replaces a container's content and animates its height from the old to the + new one. + + @param {Element|null} node The box whose height is animated. + @param {() => void} apply Renders the new content, synchronously. + @returns {void} */ +export function swapContent(node, apply) { + if (!node) { + apply(); + return; + } + const box = /** @type {HTMLElement} */ (node); + const from = box.getBoundingClientRect().height; + /* Abandon a running swap, or its cleanup lands mid-way and pins a height. */ + box.classList.remove('swapping'); + box.style.height = ''; + box.style.overflow = ''; + box.style.transition = ''; + apply(); + const to = box.getBoundingClientRect().height; + if (!(from > 0) || Math.abs(to - from) < 1) return; + box.classList.add('swapping'); + /* Inline, not a class. A disclosure's own rule is more specific and would + replace this transition with its own. */ + box.style.overflow = 'hidden'; + box.style.transition = 'height var(--t-reveal) var(--ease-reveal)'; + box.style.height = `${from}px`; + void box.offsetHeight; + box.style.height = `${to}px`; + let timer = 0; + const end = e => { + if (e && (e.target !== box || e.propertyName !== 'height')) return; + clearTimeout(timer); + box.removeEventListener('transitionend', end); + box.classList.remove('swapping'); + box.style.height = ''; + box.style.overflow = ''; + box.style.transition = ''; + }; + box.addEventListener('transitionend', end); + /* transitionend never fires if the transition is dropped, and the height + would stay pinned for the session. */ + timer = setTimeout(end, 600); +} + export const PE_SVG = ''; -export const CHEV_SVG = - ''; - /* `root` lets a caller wire a subtree that is not in the document yet. */ /** A function placeholder is resolved on use, not at wiring time: this runs before the catalog is fetched, so a translated one has to be read later. @@ -206,111 +278,6 @@ export function initInlineEdit(rowId, inputId, { type = 'text', placeholder = '' /* The listbox interaction WAI-ARIA expects for a `.row-dd` checklist. The caller owns the markup and what a toggle means. */ -export function wireChecklist(dd, btn, list, onToggle) { - const opts = () => qa('li[role="option"]', list); - let active = -1; - - const setActive = i => { - const o = opts(); - if (!o.length || i == null) return; - active = i; - o.forEach((li, n) => { - li.tabIndex = n === active ? 0 : -1; - li.classList.toggle('kb-active', n === active); - }); - o[active].focus(); - fluidHoverKb(o[active]); - }; - /* Scoped to the open state. A listener that outlives the list holds the - detached subtree it closes over, and the settings form rewires on every - render. */ - let outside = null; - const open = () => { - list.hidden = false; - btn.setAttribute('aria-expanded', 'true'); - outside = new AbortController(); - document.addEventListener( - 'click', - e => { - if (!dd.contains(e.target)) close(); - }, - { signal: outside.signal }, - ); - const o = opts(); - const first = o.findIndex(li => li.getAttribute('aria-selected') === 'true'); - setActive(first >= 0 ? first : 0); - }; - const close = ({ focusBtn = false } = {}) => { - outside?.abort(); - outside = null; - list.hidden = true; - btn.setAttribute('aria-expanded', 'false'); - opts().forEach(li => li.classList.remove('kb-active')); - fluidHoverClear(list); - if (focusBtn) btn.focus(); - }; - const toggle = li => { - onToggle(li); - }; - - btn.addEventListener('click', e => { - e.stopPropagation(); - if (list.hidden) open(); - else close(); - }); - btn.addEventListener('keydown', e => { - if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { - e.preventDefault(); - if (list.hidden) open(); - } - }); - - list.addEventListener('click', e => { - const li = e.target.closest('li[role="option"]'); - if (li) toggle(li); - }); - list.addEventListener('keydown', e => { - const o = opts(); - if (!o.length) return; - const moved = nextActiveIndex(e.key, active, o.length); - if (moved != null) { - e.preventDefault(); - setActive(moved); - return; - } - switch (e.key) { - case ' ': - case 'Enter': - e.preventDefault(); - if (o[active]) toggle(o[active]); - break; - case 'Escape': - e.preventDefault(); - close({ focusBtn: true }); - break; - case 'Tab': - close(); - break; - default: - break; - } - }); - - opts().forEach(li => { - li.tabIndex = -1; - }); - return { close }; -} - -/* An icon name resolves to several candidate URLs, and only the browser can - say which one exists. Walk them on error and fall back to a letter, or a - name that resolves to nothing leaves a broken image behind. - - Shared because it was not: the folder picker carried its own copy with no - fallback at all, and every app whose first candidate missed showed broken. - - @param {HTMLElement} host @param {string} [rawIcon] @param {string} [fallbackText] - @param {string} [imgCss] @returns {void} */ export function paintIcon(host, rawIcon, fallbackText = '?', imgCss = '') { const letter = () => { host.textContent = fallbackText; diff --git a/public/js/admin-state.js b/public/js/admin-state.js index 4a21247..4c280b2 100644 --- a/public/js/admin-state.js +++ b/public/js/admin-state.js @@ -21,6 +21,7 @@ export const state = { _evItem: null, _evIsEdit: false, + _folderApps: null, _wtype: 'custom', _wsize: 'medium', diff --git a/public/js/admin-widget-form.js b/public/js/admin-widget-form.js index 49ae244..26da3c4 100644 --- a/public/js/admin-widget-form.js +++ b/public/js/admin-widget-form.js @@ -1,10 +1,11 @@ -import { state } from '/js/admin-state.js?v=7d68e98e'; -import { PE_SVG, CHEV_SVG, initInlineEdit } from '/js/admin-shared.js?v=0f36d0dc'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=1b131080'; +import { state } from '/js/admin-state.js?v=5a5d655f'; +import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=ca64cc9c'; +import { createListbox } from '/js/listbox.js?v=3e267705'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=b0c9ade8'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=74cb4272'; +import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=69e57d35'; import { t } from '/js/i18n.js?v=e644a5c5'; -import { q, qi, qa } from '/js/utils.js?v=970a91b0'; +import { q, qi, qa } from '/js/utils.js?v=ada0c382'; const SIZE_ICONS = { small: @@ -59,16 +60,13 @@ function _renderWidgetForm(body) { const typeList = [...Object.values(state._widgetReg).map(w => [w.name, w.label]), ['custom', 'Custom']].sort((a, b) => a[1].localeCompare(b[1]), ); - const typeOpts = typeList.map( - ([t, label]) => html``, - ); const shell = document.createElement('div'); shell.className = 'grp'; setHtml( shell, html`
    ${t('widgetCfg.name')}${state._wlabel ? state._wlabel : t('widgetCfg.namePh')}
    -
    ${t('widgetCfg.type')}
    ${raw(CHEV_SVG)}
    `, +
    ${t('widgetCfg.type')}
    `, ); body.appendChild(shell); initInlineEdit('ie-wname', 'f-wlabel', { @@ -77,12 +75,18 @@ function _renderWidgetForm(body) { state._wlabel = v; }, }); - const typeSel = qi('#f-wtype', shell); - typeSel.onchange = () => { - state._wtype = typeSel.value; - state._wsize = widgetSizes(state._wtype)[0]; - _renderWidgetForm(body); - }; + const typeBox = createListbox({ + id: 'f-wtype', + label: t('widgetCfg.type'), + options: typeList.map(([name, label]) => ({ value: name, label })), + value: state._wtype, + onChange: v => { + state._wtype = String(v); + state._wsize = widgetSizes(state._wtype)[0]; + swapContent(body, () => _renderWidgetForm(body)); + }, + }); + q('#wtype-row', shell).appendChild(typeBox.el); const refused = (state._widgetRejected || []).filter(r => r && r.name); if (refused.length) { @@ -130,7 +134,7 @@ function _renderWidgetForm(body) { /* A view switch can change which sizes are offered, and the tiles are drawn above this form. */ onChange(key) { - if (_vf && key === _vf) _renderWidgetForm(body); + if (_vf && key === _vf) swapContent(body, () => _renderWidgetForm(body)); }, }); state._autoFormType = state._wtype; @@ -209,17 +213,25 @@ function _renderCustomConfig(body) { 'strict-origin', 'strict-origin-when-cross-origin', 'unsafe-url', - ].map(v => html``); + ].map(v => ({ value: v, label: v || 'Default' })); setHtml( adv, html` -
    ${t('widgetCfg.referrerPolicy')}
    ${raw(CHEV_SVG)}
    +
    ${t('widgetCfg.referrerPolicy')}
    ${t('widgetCfg.allowFeaturePolicy')}${o.allow ? o.allow : 'autoplay; fullscreen'}
    ${t('widgetCfg.allowFullscreen')}
    ${t('widgetCfg.refreshInterval')} (ms)${o.refreshInterval ? o.refreshInterval : 'e.g. 2000'}
    `, ); + const refBox = createListbox({ + label: t('widgetCfg.referrerPolicy'), + options: refOpts, + value: o.referrerPolicy || '', + onChange: () => sync(), + }); + q('#if-referrer-row', adv).appendChild(refBox.el); + const sync = () => { - state._iframeOpts.referrerPolicy = qi('#if-referrer', adv).value || undefined; + state._iframeOpts.referrerPolicy = refBox.getValue() || undefined; state._iframeOpts.allow = qi('#if-allow', adv).value.trim() || undefined; state._iframeOpts.allowFullscreen = qi('#if-fs', adv).checked; const ri = parseInt(qi('#if-refresh', adv).value, 10); diff --git a/public/js/admin.js b/public/js/admin.js index 553b5a9..a441952 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -1,7 +1,7 @@ -import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=92c86f9e'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=ee5255ba'; -import { initList, render, syncFilterUI } from '/js/admin-list.js?v=0c33cd56'; -import { resolveAdminSection } from '/js/admin-logic.js?v=74cb4272'; +import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=06ddf610'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=ae0bd2a5'; +import { initList, render, syncFilterUI } from '/js/admin-list.js?v=937c0555'; +import { resolveAdminSection } from '/js/admin-logic.js?v=69e57d35'; import { buildAppItem, claimFolderChildren, @@ -10,15 +10,16 @@ import { snapshotItems, upsertItem, } from '/js/admin-save-logic.js?v=4f71ef6c'; -import { loadSettings, showBgFields, showBgFit, showWallpaperFile } from '/js/admin-settings.js?v=23830fd0'; -import { ag, ap, initInlineEdit, paintIcon, setReauthHandler, toast } from '/js/admin-shared.js?v=0f36d0dc'; -import { collapsedFolders, filter, state } from '/js/admin-state.js?v=7d68e98e'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=0cb044d0'; +import { loadSettings, settingsDirty, showBgFields, showWallpaperFile } from '/js/admin-settings.js?v=98013f90'; +import { ag, ap, initInlineEdit, paintIcon, reveal, setReauthHandler, toast } from '/js/admin-shared.js?v=ca64cc9c'; +import { collapsedFolders, filter, state } from '/js/admin-state.js?v=5a5d655f'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=20f4cf98'; import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=8b39e9d0'; +import { createListbox } from '/js/listbox.js?v=3e267705'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=e644a5c5'; -import { loadLocalIcons } from '/js/icons.js?v=04e7796e'; +import { loadLocalIcons } from '/js/icons.js?v=9c8c550c'; import { clearSkipTls, convert, @@ -31,8 +32,8 @@ import { import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=9de1cb7d'; import { confirmModal, confirmText, openModal as openDialog, promptModal } from '/js/modal.js?v=11fa1eff'; import { readMode, watchSystemTheme, writeMode } from '/js/theme.js?v=00c011c9'; -import { el, inp, q, qa, clr as rc, sanitizeCssUrl, setUserText, tgt } from '/js/utils.js?v=970a91b0'; -import { normalizeColorInput } from '/js/admin-color-control.js?v=d162431d'; +import { el, inp, q, qa, clr as rc, sanitizeCssUrl, setUserText, tgt } from '/js/utils.js?v=ada0c382'; +import { normalizeColorInput } from '/js/admin-color-control.js?v=cfb3b3e9'; import { parseYamlTolerant, YamlLiteError } from '/js/yaml-lite.js?v=6ebb564c'; import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6'; @@ -57,7 +58,7 @@ async function load() { await initI18n(c.settings?.language || 'en'); document.title = t('nav.pageTitle'); initVersion(); - syncThemeLabel(); + syncPickerLabels(); try { const wr = await ag('/api/widgets'); state._widgetReg = Object.create(null); @@ -72,7 +73,10 @@ async function load() { state.items.filter(i => i.type === 'folder').forEach(f => collapsedFolders.add(f.id)); document.body.classList.add('authed'); render(); + _savedItems = JSON.stringify(state.items); + syncDashSave(); loadSettings(c); + syncPickerLabels(); applyBg(); } @@ -121,6 +125,7 @@ async function save() { const full = await ag('/api/config'); full.items = state.items; await ap('/api/config', full); + _savedItems = JSON.stringify(state.items); toast(t('toast.saved')); ok = true; } catch (e) { @@ -128,6 +133,7 @@ async function save() { } state.saving = false; render(); + syncDashSave(); return ok; } @@ -149,6 +155,8 @@ async function appendAndSave(newItems) { full.items = [...current, ...newItems]; await ap('/api/config', full); state.items = full.items; + _savedItems = JSON.stringify(state.items); + syncDashSave(); } finally { state.saving = false; render(); @@ -279,7 +287,12 @@ function openModal(idx) { } const isEdit = idx != null; - el('ev-title').textContent = t('nav.general'); + const evTitle = el('ev-title'); + if (isEdit) setUserText(evTitle, t('common.editNamed', { name: item.label || item.id })); + else { + evTitle.textContent = t('type.addNew'); + evTitle.removeAttribute('dir'); + } const delBtn = el('ev-delete'); const saveBtn = el('ev-save'); if (delBtn) { @@ -533,7 +546,7 @@ async function doSave(orig) { return; } /* An app belongs to one folder, or the dashboard renders it twice. */ - const children = qa('#folder-apps-list li[aria-selected="true"]', document).map(li => li.dataset.val); + const children = state._folderApps?.getValue() || []; claimFolderChildren(state.items, orig?.id, children); item = { id: @@ -606,7 +619,7 @@ function initNav() { function show(requested) { const id = resolveAdminSection(requested, sections); if (id === null) return; - if (id !== requested) console.warn('admin: unknown section', requested, '- showing', id); + if (requested && id !== requested) console.warn('admin: unknown section', requested, '- showing', id); qa('.sec', document).forEach(s => { s.hidden = s.id !== 'sec-' + id; }); @@ -731,84 +744,93 @@ async function initVersion() { function initSecToggle() { const en = inp('sec-en'); - const pwRow = el('ie-pw'); - const pwHint = el('pw-hint-static'); if (!en) return; - function apply(on) { - if (pwRow) pwRow.classList.toggle('d-none', !on); - if (pwHint) pwHint.classList.toggle('d-none', !on); + function apply(on, now = false) { + reveal(el('ie-pw-wrap'), on, now); + reveal(el('pw-hint-wrap'), on, now); } - apply(en.checked); + apply(en.checked, true); en.addEventListener('change', () => apply(en.checked)); } function initDockerToggle() { const en = inp('srv-docker-en'); - const hideRow = el('srv-hide-healthy-row'); - const socketRow = el('ie-socket'); if (!en) return; - function apply(on) { - if (hideRow) hideRow.classList.toggle('d-none', !on); - if (socketRow) socketRow.classList.toggle('d-none', !on); + function apply(on, now = false) { + reveal(el('srv-docker-rows'), on, now); + reveal(el('socket-hint-wrap'), on, now); } - apply(en.checked); + apply(en.checked, true); en.addEventListener('change', () => apply(en.checked)); } function initBgType() { - const btn = el('bg-type-btn'); - const list = el('bg-type-list'); + const slot = el('bg-type-slot'); const hidden = inp('bg-type'); - if (!btn || !list || !hidden) return; - - function setVal(val) { + if (!slot || !hidden) return; + const apply = val => { hidden.value = val; - const labels = { unsplash: 'Unsplash', url: 'Image', color: 'Solid color' }; - /* Update only the text node. The SVG chevron must survive. */ - const textNode = btn.childNodes[0]; - if (textNode && textNode.nodeType === 3) textNode.textContent = labels[val] || val; - list.querySelectorAll('li').forEach(li => li.setAttribute('aria-selected', String(li.dataset.val === val))); - list.hidden = true; showBgFields(val); const hint = el('bgcol-hint'); if (hint) hint.style.display = val === 'unsplash' ? '' : 'none'; const imgHint = el('bg-url-hint'); if (imgHint) imgHint.style.display = val === 'url' ? '' : 'none'; - } - - btn.addEventListener('click', e => { - e.stopPropagation(); - list.hidden = !list.hidden; - }); - list.querySelectorAll('li').forEach(li => { - li.addEventListener('click', () => setVal(li.dataset.val)); + }; + const box = createListbox({ + id: 'bg-type', + label: t('appearance.wallpaperSource'), + options: [ + { value: 'unsplash', label: t('appearance.sourceUnsplash') }, + { value: 'url', label: t('appearance.sourceUrl') }, + { value: 'color', label: t('appearance.sourceColor') }, + ], + value: hidden.value || 'unsplash', + onChange: v => apply(String(v)), }); - document.addEventListener('click', () => { - list.hidden = true; + slot.appendChild(box.el); + relabel(() => { + box.setLabel(t('appearance.wallpaperSource')); + box.setOptions( + [ + { value: 'unsplash', label: t('appearance.sourceUnsplash') }, + { value: 'url', label: t('appearance.sourceUrl') }, + { value: 'color', label: t('appearance.sourceColor') }, + ], + hidden.value, + ); }); - - setVal(hidden.value || 'unsplash'); + apply(hidden.value || 'unsplash'); } function initBgFit() { - const btn = el('bg-fit-btn'); - const list = el('bg-fit-list'); + const slot = el('bg-fit-slot'); const hidden = inp('bg-fit'); - if (!btn || !list || !hidden) return; - function setVal(val) { + if (!slot || !hidden) return; + const apply = val => { hidden.value = val; - showBgFit(val); - list.hidden = true; - } - btn.addEventListener('click', e => { - e.stopPropagation(); - list.hidden = !list.hidden; + }; + const box = createListbox({ + id: 'bg-fit', + label: t('appearance.fit'), + options: [ + { value: 'fill', label: t('appearance.fitFill') }, + { value: 'fit', label: t('appearance.fitContain') }, + ], + value: hidden.value || 'fill', + onChange: v => apply(String(v)), }); - list.querySelectorAll('li').forEach(li => li.addEventListener('click', () => setVal(li.dataset.val || 'fill'))); - document.addEventListener('click', () => { - list.hidden = true; + slot.appendChild(box.el); + relabel(() => { + box.setLabel(t('appearance.fit')); + box.setOptions( + [ + { value: 'fill', label: t('appearance.fitFill') }, + { value: 'fit', label: t('appearance.fitContain') }, + ], + hidden.value, + ); }); - setVal(hidden.value || 'fill'); + apply(hidden.value || 'fill'); } /** A body that is not JSON is the web server answering on its own. @@ -887,60 +909,68 @@ async function fetchWallpaperLink(url) { } function initLogLevel() { - const btn = el('log-level-btn'); - const list = el('log-level-list'); + const slot = el('log-level-slot'); const hidden = inp('log-level'); - if (!btn || !list || !hidden) return; - const labels = { debug: 'Debug', info: 'Info', error: 'Errors' }; - function setVal(val) { - hidden.value = val; - const textNode = btn.childNodes[0]; - if (textNode && textNode.nodeType === 3) textNode.textContent = labels[val] || val; - list.querySelectorAll('li').forEach(li => li.setAttribute('aria-selected', String(li.dataset.val === val))); - list.hidden = true; - } - btn.addEventListener('click', e => { - e.stopPropagation(); - list.hidden = !list.hidden; + if (!slot || !hidden) return; + const box = createListbox({ + id: 'log-level', + label: t('general.loggingLevel'), + options: [ + { value: 'debug', label: t('general.logDebug') }, + { value: 'info', label: t('general.logInfo') }, + { value: 'error', label: t('general.logError') }, + ], + value: hidden.value || 'info', + onChange: v => { + hidden.value = String(v); + }, }); - list.querySelectorAll('li').forEach(li => li.addEventListener('click', () => setVal(li.dataset.val))); - document.addEventListener('click', () => { - list.hidden = true; + slot.appendChild(box.el); + relabel(() => { + box.setLabel(t('general.loggingLevel')); + box.setOptions( + [ + { value: 'debug', label: t('general.logDebug') }, + { value: 'info', label: t('general.logInfo') }, + { value: 'error', label: t('general.logError') }, + ], + hidden.value, + ); }); - setVal(hidden.value || 'info'); + hidden.value = hidden.value || 'info'; } function initLanguage() { - const btn = el('lang-btn'); - const list = el('lang-list'); + const slot = el('lang-slot'); const hidden = inp('lang-sel'); - if (!btn || !list || !hidden) return; - const names = Object.fromEntries(LANGUAGES.map(l => [l.code, l.name])); - setHtml( - list, - html`${LANGUAGES.map(l => html`
  • ${l.name}
  • `)}`, - ); - function setVal(val) { - hidden.value = val; - const tn = btn.childNodes[0]; - if (tn && tn.nodeType === 3) tn.textContent = names[val] || val; - list.querySelectorAll('li').forEach(li => li.setAttribute('aria-selected', String(li.dataset.val === val))); - list.hidden = true; - } - btn.addEventListener('click', e => { - e.stopPropagation(); - list.hidden = !list.hidden; + if (!slot || !hidden) return; + const box = createListbox({ + id: 'lang', + label: t('common.language'), + options: LANGUAGES.map(l => ({ value: l.code, label: l.name })), + value: hidden.value || 'en', + onChange: v => { + hidden.value = String(v); + }, }); - list.querySelectorAll('li').forEach(li => li.addEventListener('click', () => setVal(li.dataset.val))); - document.addEventListener('click', () => { - list.hidden = true; + slot.appendChild(box.el); + relabel(() => { + box.setLabel(t('common.language')); + box.setValue(hidden.value); }); - setVal(hidden.value || 'en'); + hidden.value = hidden.value || 'en'; } -/* The row label follows the catalog, so it is written once the catalog is - loaded and again on every language change. */ -let syncThemeLabel = () => {}; +/* The pickers are built before the catalog loads, so each registers how to + relabel itself. Without this their options read as raw keys. */ +const relabels = []; +const relabel = fn => { + relabels.push(fn); + return fn; +}; +const syncPickerLabels = () => { + relabels.forEach(fn => fn()); +}; const THEME_LABEL_KEYS = { system: 'appearance.displaySystem', @@ -949,36 +979,47 @@ const THEME_LABEL_KEYS = { }; function initTheme() { - const btn = el('theme-btn'); - const list = el('theme-list'); + const slot = el('theme-slot'); const hidden = inp('theme-sel'); - if (!btn || !list || !hidden) return; - syncThemeLabel = () => { - const tn = btn.childNodes[0]; - if (tn && tn.nodeType === 3) tn.textContent = t(THEME_LABEL_KEYS[hidden.value] || THEME_LABEL_KEYS.system); - }; - function setVal(val) { - hidden.value = writeMode(val); - syncThemeLabel(); - list - .querySelectorAll('li') - .forEach(li => li.setAttribute('aria-selected', String(li.dataset.val === hidden.value))); - list.hidden = true; - } - btn.addEventListener('click', e => { - e.stopPropagation(); - list.hidden = !list.hidden; + if (!slot || !hidden) return; + const opts = () => [ + { value: 'system', label: t(THEME_LABEL_KEYS.system) }, + { value: 'light', label: t(THEME_LABEL_KEYS.light) }, + { value: 'dark', label: t(THEME_LABEL_KEYS.dark) }, + ]; + const box = createListbox({ + id: 'theme', + label: t('appearance.displayMode'), + options: opts(), + value: readMode(), + onChange: v => { + hidden.value = writeMode(String(v)); + box.setValue(hidden.value); + }, }); - list.querySelectorAll('li').forEach(li => li.addEventListener('click', () => setVal(li.dataset.val))); - document.addEventListener('click', () => { - list.hidden = true; + slot.appendChild(box.el); + relabel(() => { + box.setLabel(t('appearance.displayMode')); + box.setOptions(opts(), hidden.value); }); + hidden.value = writeMode(readMode()); + box.setValue(hidden.value); watchSystemTheme(() => hidden.value); - setVal(readMode()); } -const dashSaveEl = el('dash-save'); +const dashSaveEl = /** @type {HTMLButtonElement|null} */ (el('dash-save')); if (dashSaveEl) dashSaveEl.onclick = () => save(); +/* The list saves each change as it is made, so its Save lights only while a + write is pending or failed. */ +let _savedItems = ''; +function syncDashSave() { + if (dashSaveEl) dashSaveEl.disabled = JSON.stringify(state.items) === _savedItems; +} +addEventListener('beforeunload', e => { + if (!settingsDirty() && JSON.stringify(state.items) === _savedItems) return; + e.preventDefault(); + e.returnValue = ''; +}); /* Fetched, never reached by navigating a link. A link hands the request to the browser, which saves an error body under the backup's own filename. */ diff --git a/public/js/dashboard-logic.js b/public/js/dashboard-logic.js index 81d1916..6677485 100644 --- a/public/js/dashboard-logic.js +++ b/public/js/dashboard-logic.js @@ -22,6 +22,16 @@ function fingerprint(c) { /** The page index to open on, clamped to the pages that exist. @param {string|null} stored @param {number} totalPages @returns {number} */ +/* Desktop tiles keep one size. A narrower window gets fewer columns. */ +export const DESKTOP_COL = { width: 152.67, gap: 25, min: 4, max: 6 }; + +/** @param {number} avail the width the grid may take, in CSS px */ +export function desktopCols(avail) { + /* Half a pixel of slack: six 152.67px columns measure 1041.02px. */ + const fit = Math.floor((avail + DESKTOP_COL.gap + 0.5) / (DESKTOP_COL.width + DESKTOP_COL.gap)); + return Math.max(DESKTOP_COL.min, Math.min(DESKTOP_COL.max, fit)); +} + export function restorePage(stored, totalPages) { const n = Number(stored); if (stored == null || stored === '' || !Number.isInteger(n) || n < 0) return 0; diff --git a/public/js/dashboard.js b/public/js/dashboard.js index d255445..71f93d0 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -1,4 +1,4 @@ -import { loadLocalIcons, iconChain } from '/js/icons.js?v=04e7796e'; +import { loadLocalIcons, iconChain } from '/js/icons.js?v=9c8c550c'; import { WIDGET_HEIGHTS, WIDGET_DESIGN, @@ -24,18 +24,26 @@ import { setUserText, teardownWidgets, titleWhenTruncated, -} from '/js/utils.js?v=970a91b0'; +} from '/js/utils.js?v=ada0c382'; import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; -import { initSpotlight } from '/js/spotlight.js?v=2b722c16'; +import { initSpotlight } from '/js/spotlight.js?v=bcf8c942'; import { html, setHtml, raw } from '/js/html.js?v=c71f8903'; import { initI18n, t, currentLang } from '/js/i18n.js?v=e644a5c5'; import { pwStrength, passwordMismatch } from '/js/password-strength.js?v=42f45ac7'; import { sanitizeItemLinks } from '/js/link-url.js?v=54adb40f'; -import { initUI, mkFolder, openFolderDesktop, openFolderMobile, buildMobile } from '/js/ui.js?v=08ec9951'; +import { + initUI, + mkFolder, + openFolderDesktop, + openFolderMobile, + buildMobile, + resetMobileChrome, + mkFolderGlyph, +} from '/js/ui.js?v=cc6f0031'; import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=b3c8b6c2'; import { formatNumber } from '/js/format-number.js?v=4a5ccef4'; import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=aa52b1a3'; -import { configChanged, landingAfterSetup, restorePage } from '/js/dashboard-logic.js?v=74ffcb7e'; +import { configChanged, desktopCols, landingAfterSetup, restorePage } from '/js/dashboard-logic.js?v=a8f759ed'; import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6'; import { jitter } from '/js/jitter.js?v=4eeef4c9'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=9de1cb7d'; @@ -57,10 +65,8 @@ const ICON_R = 0.2237; const WIDGET_R = 28; const wCost = { d: WIDGET_COST.desktop, m: WIDGET_COST.mobile }; -const DCOLS = 6; /* The design values the stylesheet's --tile-h and --row-gap ratios were derived - from. Read gridMetrics() for the live sizes; these are only the fallback and - the divisor that turns a live tile height back into a scale factor. */ + from. Read gridMetrics() for the live sizes; these are only the fallback. */ const DESIGN_TILE = 152; const DESIGN_ROW_GAP = 30; @@ -76,24 +82,28 @@ function gridMetrics() { probe.className = 'gm-probe'; const gap = document.createElement('i'); probe.appendChild(gap); + const avail = document.createElement('b'); + probe.appendChild(avail); document.body.appendChild(probe); const tile = probe.getBoundingClientRect().height || DESIGN_TILE; const rowGap = gap.getBoundingClientRect().height || DESIGN_ROW_GAP; + const width = avail.getBoundingClientRect().width || innerWidth; probe.remove(); - return { tile, rowGap, scale: tile / DESIGN_TILE }; + return { tile, rowGap, cols: desktopCols(width) }; } -let gm = { tile: DESIGN_TILE, rowGap: DESIGN_ROW_GAP, scale: 1 }; +let gm = { tile: DESIGN_TILE, rowGap: DESIGN_ROW_GAP, cols: 6 }; /* The reserves mirror the .page padding in dashboard.css. Change one and the - page breaks stop matching the box. + page breaks stop matching the box. The bottom reserve clears the page dots, + which sit above the dock. @param {boolean} hasDock */ function desktopSlots(hasDock) { const ih = innerHeight; const top = Math.min(70, Math.max(44, ih * 0.04)); - const bottom = hasDock ? Math.min(160, Math.max(110, ih * 0.1)) : top; + const bottom = hasDock ? 204 : 68; const rows = Math.max(1, Math.min(4, Math.floor((ih - top - bottom + gm.rowGap) / (gm.tile + gm.rowGap)))); - return DCOLS * rows; + return gm.cols * rows; } const CB = { spotOpen: null, spotClose: null, mobPillBump: null }; @@ -298,8 +308,8 @@ function paginate() { function mkIcon(item) { if (item.type === 'folder') return mkFolder(item); const showLabel = S.showLabels?.desktop !== false; - const iw = Math.round((showLabel ? 72 : 78) * gm.scale), - isz = Math.round((showLabel ? 50 : 56) * gm.scale); + const iw = showLabel ? 72 : 78, + isz = showLabel ? 50 : 56; const a = item.system === 'settings' ? mk('a', { href: '/admin/' }) @@ -338,8 +348,8 @@ function mkWidget(item) { const preset = cardPreset(item, widgetReg); if (preset) card.dataset.card = preset; const design = WIDGET_DESIGN[sz] || WIDGET_DESIGN.medium; - card.style.height = Math.round(WH.d[sz] * gm.scale) + 'px'; - card.style.borderRadius = Math.round(WIDGET_R * gm.scale) + 'px'; + card.style.height = WH.d[sz] + 'px'; + card.style.borderRadius = WIDGET_R + 'px'; mountScaledWidget(card, { src: widgetSrc(item, widgetReg, { lang: currentLang() }), title: widgetTitle(item), @@ -385,8 +395,10 @@ function buildDesktop() { closeBadgePopover(); BEL.clear(); usedWidgetTitles = new Set(); + resetMobileChrome(); /* Before paginate() and before any tile is built: both size against it. */ gm = gridMetrics(); + document.documentElement.style.setProperty('--cols', String(gm.cols)); const dock = items.filter(i => i.type === 'app' && i.dock && !i.hidden).slice(0, 4); document.body.classList.toggle('no-dock', !dock.length); const pages = paginate(); @@ -847,10 +859,21 @@ async function boot() { _stateRef = state; initUI(state); initFluidHover(); - initSpotlight({ getItems: () => items, isMob: () => MOB, CB, iconChain, openFolderDesktop, openFolderMobile }); + initSpotlight({ + getItems: () => items, + isMob: () => MOB, + CB, + iconChain, + openFolderDesktop, + openFolderMobile, + folderGlyph: mkFolderGlyph, + }); + /* The width the current layout was built at. */ + let _mw = innerWidth; /* Mobile measures the viewport as it builds, so it waits for layout. */ const buildLayout = () => { + _mw = innerWidth; if (MOB) { document.body.classList.add('is-mob'); requestAnimationFrame(() => @@ -973,18 +996,25 @@ async function boot() { /* The desktop tile size follows the viewport, so a resize can change how many rows fit. Rebuild only when the slot count actually moves, not on every pixel: a rebuild tears down and remounts every widget iframe. */ + const hasDock = () => items.some(i => i.type === 'app' && i.dock && !i.hidden); let _dz, - _slots = desktopSlots(); + _slots = desktopSlots(hasDock()); + /* The phone layout rebuilds on a width change only. A phone keyboard changes + the height, and a rebuild then would close it. */ let _rs; window.addEventListener('resize', () => { clearTimeout(_rs); - _rs = setTimeout(resampleBg, 200); + _rs = setTimeout(() => { + resampleBg(); + if (MOB && innerWidth !== _mw) buildLayout(); + }, 200); if (MOB) return; clearTimeout(_dz); _dz = setTimeout(() => { if (MOB) return; gm = gridMetrics(); - const slots = desktopSlots(); + document.documentElement.style.setProperty('--cols', String(gm.cols)); + const slots = desktopSlots(hasDock()); if (slots === _slots) return; _slots = slots; buildDesktop(); diff --git a/public/js/icons.js b/public/js/icons.js index 052dec2..cadeb93 100644 --- a/public/js/icons.js +++ b/public/js/icons.js @@ -3,15 +3,19 @@ user-uploaded SVG loaded that way cannot execute script. Inlined into the DOM it can. */ const LOCAL_ICONS = new Set(); +/* The demo blocks outbound requests, so its icon proxy only ever fails. */ +let _proxy = true; export async function loadLocalIcons() { try { const r = await fetch('/api/icons/local', { cache: 'no-store' }); if (r.ok) { + const d = await r.json(); /* Mutate the existing Set. Reassigning it leaves every other module holding a stale reference. */ LOCAL_ICONS.clear(); - ((await r.json()).files || []).forEach(f => LOCAL_ICONS.add(f)); + (d.files || []).forEach(f => LOCAL_ICONS.add(f)); + _proxy = d.demo !== true; } } catch {} } @@ -103,8 +107,8 @@ export function iconChain(rawIcon) { /* One request, not one per format. Hundreds of catalogue entries are png only and hundreds have no png, and the server knows which from the catalogue index it already holds. */ - if (explicitExt) chain.push(`/api/icons/cdn?name=${cdn}&ext=${explicitExt}${src}`); - else chain.push(`/api/icons/cdn?name=${cdn}${src}`); + if (_proxy && explicitExt) chain.push(`/api/icons/cdn?name=${cdn}&ext=${explicitExt}${src}`); + else if (_proxy) chain.push(`/api/icons/cdn?name=${cdn}${src}`); for (const ext of ['svg', 'png']) { if (explicitExt && explicitExt !== ext) continue; const direct = cdnFileUrl(source, cdn, ext); diff --git a/public/js/listbox.js b/public/js/listbox.js new file mode 100644 index 0000000..0b425a8 --- /dev/null +++ b/public/js/listbox.js @@ -0,0 +1,344 @@ +// @ts-check +/* The picker for every list in the admin. + + Keep the list on . Inside its row the settings panel clips it. + + Do not use popover. The top layer makes iOS collapse its browser toolbar, + which exposes a strip of page canvas and misplaces the menu. */ + +import { nextActiveIndex } from '/js/admin-logic.js?v=69e57d35'; +import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; +import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; +import { qa } from '/js/utils.js?v=ada0c382'; + +const CHEV = + ''; + +const TYPEAHEAD_MS = 800; + +let seq = 0; + +/* The lists live on while their rows do not, and the admin rebuilds a + form by replacing its children. Without this every rebuild strands one list + per picker on the page for the rest of the session. */ +const mounted = new Set(); +function pruneDetached() { + for (const m of mounted) { + /* `placed` is set a microtask after creation, by which time the caller has + attached the row. Before that the entry is new, not abandoned. */ + if (!m.placed || m.dd.isConnected) continue; + m.list.remove(); + mounted.delete(m); + } +} + +const FOCUSABLE = 'a[href],button,input,select,textarea,[tabindex]'; + +/** The list is last on , so the browser's own Tab would leave the form. + + @param {HTMLElement} from @param {1|-1} step */ +function focusBeside(from, step) { + const all = qa(FOCUSABLE, document).filter( + n => + n === from || + (n.tabIndex >= 0 && + !(/** @type {any} */ (n).disabled) && + n.getClientRects().length && + !n.closest('.row-dd-list')), + ); + const next = all[all.indexOf(from) + step]; + (next || from).focus(); +} + +/** An entry with `group` and no `value` is a heading. The keyboard skips it. + + @typedef {{ value?: string, label?: string, group?: string }} ListboxOption */ + +/** Builds a picker and returns it with its own small API. + + @param {{ options?: ListboxOption[], value?: string|string[], + multiple?: boolean, label: string, placeholder?: string, + summary?: (selected: ListboxOption[]) => string, + onChange?: (value: any) => void, emptyText?: string, + id?: string }} opts + @returns {{ el: HTMLElement, button: HTMLElement, list: HTMLElement, + getValue: () => any, setValue: (v: any) => void, + setOptions: (o: ListboxOption[], v?: any) => void, + setLabel: (l: string) => void, + close: () => void }} */ +export function createListbox( + { + options = [], + value, + multiple = false, + label, + placeholder = '', + summary, + onChange, + emptyText = '', + id = '', + } = /** @type {any} */ ({}), +) { + let opts = options.slice(); + let chosen = multiple + ? new Set((Array.isArray(value) ? value : []).map(String)) + : new Set(value == null || value === '' ? [] : [String(value)]); + + /* The list is not a descendant of its button, so the two are tied by id. */ + const listId = id ? `${id}-list` : `lb-${++seq}`; + const dd = document.createElement('div'); + dd.className = 'row-dd'; + setHtml( + dd, + html` + `, + ); + const btn = /** @type {HTMLElement} */ (dd.querySelector('.row-dd-btn')); + const text = /** @type {HTMLElement} */ (dd.querySelector('.row-dd-text')); + const list = /** @type {HTMLElement} */ (dd.querySelector('.row-dd-list')); + pruneDetached(); + list.remove(); + list.hidden = true; + document.body.appendChild(list); + const entry = { dd, list, placed: false }; + mounted.add(entry); + queueMicrotask(() => { + entry.placed = true; + }); + + const selected = () => opts.filter(o => o.group == null && chosen.has(String(o.value))); + const items = () => qa('li[role="option"]', list); + + function paintButton() { + const sel = selected(); + if (summary) text.textContent = summary(sel); + else if (!sel.length) text.textContent = placeholder; + else text.textContent = sel.map(o => o.label).join(', '); + text.classList.toggle('is-ph', !sel.length && !summary); + } + + function paintList() { + if (!opts.length && emptyText) { + setHtml(list, html`
  • ${emptyText}
  • `); + return; + } + setHtml( + list, + html`${opts.map(o => + o.group != null + ? html`` + : html`
  • ${o.label}
  • `, + )}`, + ); + items().forEach(li => { + li.tabIndex = -1; + }); + } + + function paint() { + paintList(); + paintButton(); + } + + let active = -1; + let open_ = false; + /* Scoped to the open state. A listener that outlives the list holds the + detached row it closes over, and the admin rewires on every render. */ + let outside = null; + + function place() { + const r = btn.getBoundingClientRect(); + const pr = list.getBoundingClientRect(); + /* Not the layout viewport. iOS grows the visible area past it. */ + const vh = visualViewport?.height ?? window.innerHeight; + const vw = visualViewport?.width ?? window.innerWidth; + const gap = 4; + let top = r.bottom + gap; + if (top + pr.height > vh - 8) top = Math.max(8, r.top - gap - pr.height); + const rtl = (document.documentElement.getAttribute('dir') || 'ltr') === 'rtl'; + let left = rtl ? r.left : r.right - pr.width; + left = Math.min(Math.max(8, left), vw - pr.width - 8); + list.style.top = `${Math.round(top)}px`; + list.style.left = `${Math.round(left)}px`; + } + + const setActive = i => { + const o = items(); + if (!o.length || i == null) return; + active = i; + o.forEach((li, n) => { + li.tabIndex = n === active ? 0 : -1; + li.classList.toggle('kb-active', n === active); + }); + o[active].focus(); + fluidHoverKb(o[active]); + }; + + const open = () => { + if (open_) return; + list.hidden = false; + open_ = true; + btn.setAttribute('aria-expanded', 'true'); + place(); + outside = new AbortController(); + /* The visible area resizes a frame after the menu opens. */ + visualViewport?.addEventListener('resize', place, { signal: outside.signal }); + visualViewport?.addEventListener('scroll', place, { signal: outside.signal }); + /* A scroll event lands a frame late. Close only when the button moved. */ + const at = btn.getBoundingClientRect(); + document.addEventListener( + 'scroll', + e => { + if (list.contains(/** @type {Node} */ (e.target))) return; + const r = btn.getBoundingClientRect(); + if (Math.abs(r.top - at.top) > 1 || Math.abs(r.left - at.left) > 1) close(); + }, + { capture: true, signal: outside.signal }, + ); + document.addEventListener( + 'click', + e => { + const t = /** @type {Node} */ (e.target); + if (!dd.contains(t) && !list.contains(t)) close(); + }, + { signal: outside.signal }, + ); + const o = items(); + const first = o.findIndex(li => li.getAttribute('aria-selected') === 'true'); + setActive(first >= 0 ? first : 0); + }; + + const close = ({ focusBtn = false } = {}) => { + outside?.abort(); + outside = null; + list.hidden = true; + open_ = false; + btn.setAttribute('aria-expanded', 'false'); + items().forEach(li => li.classList.remove('kb-active')); + fluidHoverClear(list); + if (focusBtn) btn.focus(); + }; + + function commit(li) { + const val = li.dataset.val; + if (val == null) return; + if (multiple) { + if (chosen.has(val)) chosen.delete(val); + else chosen.add(val); + const keep = active; + paint(); + setActive(keep); + } else { + chosen = new Set([val]); + paint(); + close({ focusBtn: true }); + } + onChange?.(getValue()); + } + + let typed = ''; + let typedAt = 0; + function typeahead(key) { + const now = Date.now(); + typed = now - typedAt > TYPEAHEAD_MS ? key : typed + key; + typedAt = now; + const o = items(); + const from = typed.length === 1 ? active + 1 : active; + for (let n = 0; n < o.length; n++) { + const i = (from + n + o.length) % o.length; + if ((o[i].textContent || '').trim().toLowerCase().startsWith(typed.toLowerCase())) { + setActive(i); + return true; + } + } + return false; + } + + btn.addEventListener('click', e => { + e.stopPropagation(); + if (open_) close(); + else open(); + }); + btn.addEventListener('keydown', e => { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') { + if (!open_) { + e.preventDefault(); + open(); + } + } + }); + + list.addEventListener('click', e => { + const li = /** @type {HTMLElement} */ (e.target).closest('li[role="option"]'); + if (li) commit(/** @type {HTMLElement} */ (li)); + }); + list.addEventListener('keydown', e => { + const o = items(); + if (!o.length) return; + const moved = nextActiveIndex(e.key, active, o.length); + if (moved != null) { + e.preventDefault(); + setActive(moved); + return; + } + switch (e.key) { + case ' ': + case 'Enter': + e.preventDefault(); + if (o[active]) commit(o[active]); + break; + case 'Escape': + e.preventDefault(); + close({ focusBtn: true }); + break; + case 'Tab': + e.preventDefault(); + close(); + focusBeside(btn, e.shiftKey ? -1 : 1); + break; + default: + if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey && typeahead(e.key)) e.preventDefault(); + break; + } + }); + + const getValue = () => + multiple + ? opts + .filter(o => o.group == null) + .map(o => String(o.value)) + .filter(v => chosen.has(v)) + : [...chosen][0]; + + paint(); + + return { + el: dd, + button: btn, + list, + getValue, + setValue(v) { + chosen = multiple + ? new Set((Array.isArray(v) ? v : []).map(String)) + : new Set(v == null || v === '' ? [] : [String(v)]); + paint(); + }, + setOptions(next, v) { + opts = (next || []).slice(); + if (v !== undefined) { + chosen = multiple + ? new Set((Array.isArray(v) ? v : []).map(String)) + : new Set(v == null || v === '' ? [] : [String(v)]); + } else if (!multiple) { + /* A refreshed list may no longer carry what was chosen. */ + const still = [...chosen].filter(c => opts.some(o => String(o.value) === c)); + chosen = new Set(still); + } + paint(); + }, + setLabel(next) { + list.setAttribute('aria-label', next); + }, + close: () => close(), + }; +} diff --git a/public/js/spotlight.js b/public/js/spotlight.js index 8121b42..0e72274 100644 --- a/public/js/spotlight.js +++ b/public/js/spotlight.js @@ -1,5 +1,5 @@ import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; -import { mk, clr, el, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=970a91b0'; +import { mk, clr, el, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=ada0c382'; import { t } from '/js/i18n.js?v=e644a5c5'; /* Attached to the window so a re-open can undo the previous one. */ @@ -7,7 +7,7 @@ const _w = /** @type {any} */ (window); /* `isMob` is a function, not a flag: the window can cross the breakpoint while this module is loaded. */ -export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDesktop, openFolderMobile }) { +export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDesktop, openFolderMobile, folderGlyph }) { const MOB = () => isMob(); const ov = /** @type {HTMLDialogElement} */ (el('spot')); const inp = inpById('sin'); @@ -15,7 +15,8 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto const cancelBtn = el('spot-cancel'); const live = el('sres-live'); let si = 0, - cur = []; + cur = [], + returnFocus = null; inp.setAttribute('role', 'combobox'); inp.setAttribute('aria-autocomplete', 'list'); @@ -64,15 +65,10 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto const doOpen = () => { close(); if (isFolder) { - if (MOB()) - openFolderMobile( - app, - Math.round(60 * (innerWidth / 393)), - Math.round(14 * (innerWidth / 393)), - Math.round(38 * (innerWidth / 393)), - innerWidth / 393, - ); - else openFolderDesktop(app); + if (MOB()) { + const s = Math.min(innerWidth, 430) / 393; + openFolderMobile(app, Math.round(60 * s), Math.round(14 * s), Math.round(38 * s), s); + } else openFolderDesktop(app); } else if (app.system === 'settings' && app.href) { window.location.href = app.href; } else if (app.href) { @@ -88,7 +84,11 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto const ic = mk('div'); ic.className = 'sri'; ic.style.background = clr(app.color); - if (app.iconUrl) { + if (isFolder && folderGlyph) { + ic.style.background = 'transparent'; + ic.style.position = 'relative'; + ic.appendChild(folderGlyph(app, 44)); + } else if (app.iconUrl) { const img = mk('img', { alt: '', loading: 'lazy' }); img.setAttribute('aria-hidden', 'true'); const srcs = iconChain(app.iconUrl); @@ -176,6 +176,7 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto function open(ch) { if (ov.open) return; + returnFocus = document.activeElement; ov.showModal(); ov.classList.add('on'); inp.value = ch || ''; @@ -218,6 +219,13 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto ov.classList.remove('on'); inp.value = ''; res.replaceChildren(); + const back = returnFocus; + returnFocus = null; + /* Only while focus is still inside the closed dialog. A folder opened from + a result has already taken it. */ + if (!ov.contains(document.activeElement)) return; + if (back instanceof HTMLElement && back !== document.body && back.isConnected) back.focus(); + else inp.blur(); }); function close() { diff --git a/public/js/ui.js b/public/js/ui.js index a1464e5..f9d6ff5 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -1,4 +1,4 @@ -import { iconChain } from '/js/icons.js?v=04e7796e'; +import { iconChain } from '/js/icons.js?v=9c8c550c'; import { widgetSrc, cardPreset, uniqueTitle, WIDGET_DESIGN } from '/js/widget-types.js?v=a1b61636'; import { mk, @@ -13,7 +13,7 @@ import { q, qa, setUserText, -} from '/js/utils.js?v=970a91b0'; +} from '/js/utils.js?v=ada0c382'; import { t, currentLang } from '/js/i18n.js?v=e644a5c5'; import { toneForColor } from '/js/label-contrast.js?v=c1ac6fb8'; import { mobileMetrics, gridColumnWidth, gridCellCount } from '/js/mobile-metrics.js?v=ab5fe77e'; @@ -101,6 +101,28 @@ function mkMiniIcon(child, pointerEvents) { return bg; } +/** The miniature icon grid a folder tile shows. @param {number} size */ +export function mkFolderGlyph(folder, size) { + const wrap = mk('div'); + wrap.className = 'dyn-fold-wrap'; + css(wrap, { + '--br': Math.round(size * 0.24) + 'px', + '--gap': Math.round(size * 0.04) + 'px', + '--pad': Math.round(size * 0.1) + 'px', + }); + (folder.children || []) + .slice(0, 9) + .map(id => items().find(i => i.id === id)) + .filter(Boolean) + .forEach(child => { + const cell = mk('div'); + cell.className = 'dyn-fold-cell'; + cell.appendChild(mkMiniIcon(child, 'none')); + wrap.appendChild(cell); + }); + return wrap; +} + export function mkFolder(item) { const showLabel = S().showLabels?.desktop !== false; const iw = showLabel ? 72 : 78; @@ -283,24 +305,10 @@ function mFolder(item, cw, rh, isz, ir, im, sc) { box.className = 'dyn-sz dyn-box'; css(box, { '--sz': eff + 'px' }); box.style.pointerEvents = 'none'; - const wrap = mk('div'); - const pad = Math.round(eff * 0.1), - gap = Math.round(eff * 0.04); - wrap.className = 'dyn-fold-wrap'; - css(wrap, { '--br': Math.round(eff * 0.24) + 'px', '--gap': gap + 'px', '--pad': pad + 'px' }); + const wrap = mkFolderGlyph(item, eff); const sheen = mk('div'); sheen.className = 'dyn-fold-sheen'; - wrap.appendChild(sheen); - (item.children || []) - .slice(0, 9) - .map(id => items().find(i => i.id === id)) - .filter(Boolean) - .forEach(child => { - const cell = mk('div'); - cell.className = 'dyn-fold-cell'; - cell.appendChild(mkMiniIcon(child, 'none')); - wrap.appendChild(cell); - }); + wrap.prepend(sheen); box.appendChild(wrap); const fb_ = mk('div'); fb_.className = 'badge'; @@ -319,6 +327,8 @@ function mFolder(item, cw, rh, isz, ir, im, sc) { } let folderOverlayMob = null; +/* The widest phone viewport. Matches the cap in mobile-metrics.js. */ +export const FOLDER_MAX_VW = 430; export function openFolderMobile(folder, isz, _ir, _im, sc) { if (folderOverlayMob) { folderOverlayMob.remove(); @@ -347,9 +357,12 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) { folderOverlayMob = null; }); - const ptScale = vw / 393; + /* Phone size on every window. A wider window centres the folder instead of + enlarging it. */ + const ptScale = Math.min(vw, FOLDER_MAX_VW) / 393; const margin = Math.round(34 * ptScale), - boxW = vw - margin * 2; + boxW = Math.min(vw, FOLDER_MAX_VW) - margin * 2, + boxLeft = Math.round((vw - boxW) / 2); const padH = Math.round(20 * ptScale), padVT = Math.round(24 * ptScale), padVB = Math.round(22 * ptScale); @@ -376,7 +389,7 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) { const titleFs = Math.round(30 * ptScale), titleGap = Math.round(40 * ptScale); const titleRendH = Math.ceil(titleFs * 1.05) + Math.round(4 * ptScale); - const titleLeft = margin + padH + Math.round(6 * ptScale); + const titleLeft = boxLeft + padH + Math.round(6 * ptScale); const titleEl = mk('div'); titleEl.className = 'folder-title-mobile dyn-title-mob'; @@ -391,7 +404,7 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) { const box = mk('div'); box.className = 'folder-box-mobile dyn-box-mob'; css(box, { - '--left': margin + 'px', + '--left': boxLeft + 'px', '--bw': boxW + 'px', '--bh': boxH + 'px', '--top': boxTop + 'px', @@ -520,6 +533,9 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) { ); ov.appendChild(titleEl); ov.appendChild(box); + ov.onclick = e => { + if (e.target === ov) closeMob(); + }; ov.addEventListener( 'touchend', e => { @@ -817,6 +833,12 @@ export function buildMobile() { pillNew.onclick = () => { if (CB().spotOpen) CB().spotOpen(''); }; + pillNew.onkeydown = e => { + if (e.key !== 'Enter' && e.key !== ' ') return; + e.preventDefault(); + e.stopPropagation(); + if (CB().spotOpen) CB().spotOpen(''); + }; let _pillIdleTimer = null; function pillPaging(on) { @@ -849,3 +871,20 @@ export function buildMobile() { document.addEventListener('touchstart', st()._mobTsCleanup, { passive: true }); document.addEventListener('touchend', st()._mobTeCleanup, { passive: true }); } + +/* The desktop build shares the dock and pill elements with buildMobile, which + styles them inline. */ +export function resetMobileChrome() { + const dk = el('dock'); + dk.className = ''; + dk.style.cssText = ''; + const pill = el('mob-search-pill'); + pill.style.cssText = ''; + pill.classList.remove('paging'); + CB().mobPillBump = null; + const { _mobTsCleanup, _mobTeCleanup } = st(); + if (_mobTsCleanup) document.removeEventListener('touchstart', _mobTsCleanup); + if (_mobTeCleanup) document.removeEventListener('touchend', _mobTeCleanup); + st()._mobTsCleanup = null; + st()._mobTeCleanup = null; +} diff --git a/public/js/utils.js b/public/js/utils.js index f75d3c0..23b47f9 100644 --- a/public/js/utils.js +++ b/public/js/utils.js @@ -1,4 +1,4 @@ -import { iconChain } from '/js/icons.js?v=04e7796e'; +import { iconChain } from '/js/icons.js?v=9c8c550c'; import { toneForColor } from '/js/label-contrast.js?v=c1ac6fb8'; import { SETTINGS_ICON } from '/js/settings-icon.js?v=b96e5b13'; import { BRAND_MARK } from '/js/brand-mark.js?v=1dcbf1ac'; diff --git a/public/js/widget-config-form.js b/public/js/widget-config-form.js index e36b481..e7b82ff 100644 --- a/public/js/widget-config-form.js +++ b/public/js/widget-config-form.js @@ -4,8 +4,9 @@ import { t } from '/js/i18n.js?v=e644a5c5'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { wireChecklist } from '/js/admin-shared.js?v=0f36d0dc'; -import { renderColorControl } from '/js/admin-color-control.js?v=d162431d'; +import { reveal } from '/js/admin-shared.js?v=ca64cc9c'; +import { createListbox } from '/js/listbox.js?v=3e267705'; +import { renderColorControl } from '/js/admin-color-control.js?v=cfb3b3e9'; import { seedCarried, applyOptionSet, @@ -13,14 +14,12 @@ import { requiredFieldMissing, groupBounds, visibleFieldFlags, -} from '/js/admin-logic.js?v=74cb4272'; +} from '/js/admin-logic.js?v=69e57d35'; import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1'; -import { qi } from '/js/utils.js?v=970a91b0'; +import { qi } from '/js/utils.js?v=ada0c382'; const PE = ''; -const CHEV = - ''; /* A counter, not randomness. Six base-36 characters collide plausibly over a long session, and an id that changes per run cannot be asserted on. */ @@ -231,38 +230,26 @@ function _picklist(field, value, ctx, size) { let opts = Array.isArray(field.options) ? field.options.slice() : []; const sels = []; + const shown = cur => { + const out = opts.map(o => ({ value: String(o.value), label: o.label != null ? String(o.label) : String(o.value) })); + if (cur && !opts.some(o => String(o.value) === cur)) out.unshift({ value: cur, label: cur }); + out.unshift({ value: '', label: t('widgetCfg.empty') }); + return out; + }; for (let i = 0; i < count; i++) { const row = document.createElement('div'); row.className = 'row'; setHtml(row, html`${rowLabel} ${i + 1}`); - const selWrap = document.createElement('div'); - selWrap.className = 'sel-wrap'; - const sel = document.createElement('select'); - sel.className = 'row-sel'; - sel.setAttribute('aria-label', `${rowLabel} ${i + 1}`); - selWrap.appendChild(sel); - const chevT = document.createElement('template'); - setHtml(chevT, raw(CHEV)); - selWrap.appendChild(chevT.content.firstElementChild); - row.appendChild(selWrap); + const cur = chosen[i] != null ? String(chosen[i]) : ''; + const lb = createListbox({ label: `${rowLabel} ${i + 1}`, options: shown(cur), value: cur }); + row.appendChild(lb.el); card.appendChild(row); - sels.push(sel); + sels.push(lb); } function paint() { - sels.forEach((sel, i) => { - const cur = sel.value || (chosen[i] != null ? String(chosen[i]) : ''); - const items = opts.map( - o => - html``, - ); - if (cur && !opts.some(o => String(o.value) === cur)) - items.unshift(html``); - items.unshift(html``); - setHtml(sel, html`${items}`); - }); + sels.forEach(lb => lb.setOptions(shown(lb.getValue() || ''), lb.getValue() || '')); } - paint(); if (field.optionsFrom) { btn.addEventListener('click', async () => { @@ -286,7 +273,7 @@ function _picklist(field, value, ctx, size) { btn.style.display = 'none'; } - const get = () => [field.key, sels.map(s => s.value || null)]; + const get = () => [field.key, sels.map(lb => lb.getValue() || null)]; return { el: wrap, get, control: null, liveValue: () => null }; } @@ -299,38 +286,46 @@ function _select(field, value, ctx, config = {}) { let carried = seedCarried(config, carryKeys); let chosen = value != null ? String(value) : field.default != null ? String(field.default) : ''; setHtml(row, html`${field.label}${_tag(field)}`); - const selWrap = document.createElement('div'); - selWrap.className = 'sel-wrap'; - const sel = document.createElement('select'); - sel.className = 'row-sel'; - selWrap.appendChild(sel); - const chevT = document.createElement('template'); - setHtml(chevT, raw(CHEV)); - selWrap.appendChild(chevT.content.firstElementChild); - row.appendChild(selWrap); + /* Keep a stored value the list does not offer, or opening the picker + discards it. */ + const shown = () => { + const out = opts.map(o => ({ value: String(o.value), label: o.label != null ? String(o.label) : String(o.value) })); + if (chosen && !opts.some(o => String(o.value) === chosen)) out.unshift({ value: chosen, label: chosen }); + if (field.optional || !out.length) out.unshift({ value: '', label: t('widgetCfg.none') }); + return out; + }; + /* A native select lands on its first option when nothing is stored. Fetched + options arrive after the field is built, so this runs on every paint. */ + const normalise = () => { + if (!field.optional && !chosen && opts.length) chosen = String(opts[0].value); + }; + normalise(); + const lb = createListbox({ + label: field.label, + options: shown(), + value: chosen, + onChange: v => { + chosen = v == null ? '' : String(v); + syncCarried(); + wrap.dispatchEvent(new Event('change')); + }, + }); + row.appendChild(lb.el); wrap.appendChild(row); - function paint() { - const items = opts.map( - o => - html``, - ); - if (chosen && !opts.some(o => String(o.value) === chosen)) - items.unshift(html``); - if (field.optional) items.unshift(html``); - setHtml(sel, items.length ? html`${items}` : html``); - } - paint(); + const paint = () => { + normalise(); + lb.setOptions(shown(), chosen); + }; function syncCarried() { if (!carryKeys.length) return; carried = applyOptionSet( carried, - opts.find(x => String(x.value) === sel.value), + opts.find(x => String(x.value) === chosen), carryKeys, ); } - sel.addEventListener('change', syncCarried); if (field.optionsFrom) { const fr = document.createElement('div'); @@ -352,7 +347,7 @@ function _select(field, value, ctx, config = {}) { btn.disabled = true; try { opts = await _fetchOptions(field, ctx); - chosen = sel.value || chosen; + chosen = lb.getValue() || chosen; paint(); syncCarried(); status.textContent = opts.length ? t('widgetCfg.loaded', { count: opts.length }) : t('widgetCfg.noOptions'); @@ -372,8 +367,8 @@ function _select(field, value, ctx, config = {}) { wrap.appendChild(h); } - const get = () => [field.key, sel.value, carryKeys.length ? carried : null]; - return { el: wrap, get, control: sel, liveValue: () => sel.value }; + const get = () => [field.key, chosen, carryKeys.length ? carried : null]; + return { el: wrap, get, control: wrap, liveValue: () => chosen }; } function _pills(field, value) { @@ -393,10 +388,35 @@ function _pills(field, value) { r.addEventListener('change', () => { if (r.checked) { sel = r.value; - group.dispatchEvent(new Event('change')); + wrap.dispatchEvent(new Event('change')); } }), ); + + /* A segmented control cannot shrink: its labels do not wrap, so it can be + wider than the screen. When it does not fit it becomes the picker. */ + const label = row.querySelector('.rl'); + const fits = () => { + const room = row.clientWidth - label.getBoundingClientRect().width - 24; + return room <= 0 || group.scrollWidth <= room; + }; + if (typeof ResizeObserver === 'function') { + const ro = new ResizeObserver(() => { + if (fits()) return; + ro.disconnect(); + const box = createListbox({ + label: field.label, + options: opts.map(o => ({ value: String(o.value), label: String(o.label) })), + value: sel, + onChange: v => { + sel = String(v); + wrap.dispatchEvent(new Event('change')); + }, + }); + group.replaceWith(box.el); + }); + ro.observe(row); + } if (field.hint) { const h = document.createElement('p'); h.className = 'grp-tip in-card'; @@ -404,7 +424,7 @@ function _pills(field, value) { wrap.appendChild(h); } const get = () => [field.key, sel]; - return { el: wrap, get, control: group, liveValue: () => sel }; + return { el: wrap, get, control: wrap, liveValue: () => sel }; } /* ── Multi-select → checklist dropdown (tap to toggle), value is array in declared order. ── */ @@ -413,29 +433,27 @@ function _multiselect(field, value) { const row = document.createElement('div'); row.className = 'row'; const opts = Array.isArray(field.options) ? field.options : []; - const cur = new Set( + let cur = new Set( Array.isArray(value) ? value.map(String) : Array.isArray(field.default) ? field.default.map(String) : [], ); - const summary = () => - cur.size === 0 ? t('widgetCfg.noneSelected') : t('widgetCfg.selectedCount', { count: cur.size }); - setHtml( - row, - html`${field.label}
    `, - ); - wrap.appendChild(row); - const dd = row.querySelector('.row-dd'); - const btn = row.querySelector('.row-dd-btn'), - list = row.querySelector('.row-dd-list'), - sumEl = row.querySelector('.ms-sum'); - wireChecklist(dd, btn, list, li => { - const v = li.dataset.val, - on = li.getAttribute('aria-selected') !== 'true'; - li.setAttribute('aria-selected', String(on)); - if (on) cur.add(v); - else cur.delete(v); - sumEl.textContent = summary(); - wrap.dispatchEvent(new Event('change')); + /* Reads what it is handed, not `cur`. The button is painted before the + change is reported back. */ + const summary = sel => + sel.length === 0 ? t('widgetCfg.noneSelected') : t('widgetCfg.selectedCount', { count: sel.length }); + setHtml(row, html`${field.label}`); + const lb = createListbox({ + label: field.label, + multiple: true, + options: opts.map(o => ({ value: String(o.value), label: String(o.label) })), + value: [...cur], + summary, + onChange: v => { + cur = new Set(v); + wrap.dispatchEvent(new Event('change')); + }, }); + row.appendChild(lb.el); + wrap.appendChild(row); if (field.hint) { const h = document.createElement('p'); h.className = 'grp-tip in-card'; @@ -470,7 +488,7 @@ function _color(field, value) { } function _visible(b) { - return b.el.style.display !== 'none'; + return b.wrap ? b.wrap.classList.contains('open') : b.el.style.display !== 'none'; } function _missingIn(built) { @@ -494,12 +512,44 @@ function _wireShowIf(built) { for (const b of built) if (b.liveValue) liveByKey[b.field.key] = b.liveValue; const fields = built.map(b => b.field); const readValue = key => (liveByKey[key] ? liveByKey[key]() : undefined); + /* The wrapper takes the row's own place, so a single-row field keeps the + wrapper that draws its separator. */ + for (const b of built) { + if (!b.field.showIf || !b.el.parentNode) continue; + const wrap = document.createElement('div'); + wrap.className = b.el.classList.contains('row') ? 'row-wrap reveal' : 'reveal'; + const inner = document.createElement('div'); + inner.className = 'reveal-in'; + b.el.replaceWith(wrap); + inner.appendChild(b.el); + wrap.appendChild(inner); + b.wrap = wrap; + } + /* A card with nothing left in it still draws its border and claims its gap. */ + const cards = [...new Set(built.map(b => b.el.closest('.grp')).filter(Boolean))]; + const cardHasRow = card => + [...card.querySelectorAll('.row')].some(r => !r.classList.contains('d-none') && !r.closest('.reveal:not(.open)')); + const syncCards = () => { + for (const card of cards) { + const empty = !cardHasRow(card); + card.classList.toggle('grp-empty', empty); + const hdr = card.previousElementSibling; + if (hdr?.classList.contains('grp-hdr')) hdr.classList.toggle('grp-empty', empty); + } + }; + + let first = true; const apply = () => { const shown = visibleFieldFlags(fields, readValue); built.forEach((b, i) => { if (!b.field.showIf) return; - b.el.style.display = shown[i] ? '' : 'none'; + if (b.wrap) reveal(b.wrap, shown[i], first); + else b.el.style.display = shown[i] ? '' : 'none'; }); + /* Again after the transition: a closing card still holds its rows. */ + syncCards(); + setTimeout(syncCards, 400); + first = false; }; for (const b of built) { if (b.control) b.control.addEventListener('change', apply); diff --git a/public/widgets/clock/analog.html b/public/widgets/clock/analog.html index 7f01396..ee8ae50 100644 --- a/public/widgets/clock/analog.html +++ b/public/widgets/clock/analog.html @@ -74,7 +74,7 @@ }; let _mode='dark', _timezone='', _showDate=true; -let _groups=null, _dateText=null, _rafId=null, _running=false, _vb=0; +let _groups=null, _dateTexts=[], _rafId=null, _running=false, _vb=0; function _now(){ if(!_timezone) return new Date(); @@ -114,11 +114,16 @@ face.appendChild(el('rect',{ x:0, y:0, width:V, height:V, fill:dial.fill })); } - if(_showDate){ - _dateText=el('text',{ x:cx, y:V-V*0.085, 'text-anchor':'middle', + _dateTexts=[]; + const dateOf=clip=>{ + if(!_showDate) return; + const n=el('text',{ x:cx, y:V-V*0.085, 'text-anchor':'middle', 'font-size':V*0.072, fill:dial.date }); - face.appendChild(_dateText); - } else { _dateText=null; } + if(clip) n.setAttribute('clip-path', clip); + _dateTexts.push(n); + face.appendChild(n); + }; + dateOf(null); const dist=V*0.085; _groups={ minute:{discs:[],ticks:[]}, hour:{discs:[],ticks:[]}, second:{discs:[],ticks:[]} }; @@ -148,18 +153,24 @@ const inMinute=clipOf('clip-minute','minute',{}); const inHour=clipOf('clip-hour','hour',{}); const inMinuteHour=clipOf('clip-minute-hour','hour',{ 'clip-path':inMinute }); + const inSecond=clipOf('clip-second','second',{}); + /* The date stays under the hands. Each disc repeats it, clipped to the disc, + so it shows through tinted like the disc overlaps. */ face.appendChild(discOf('minute',{ fill:DISC.minute.color })); + dateOf(inMinute); face.appendChild(tickOf('minute')); face.appendChild(discOf('hour',{ fill:DISC.hour.color })); face.appendChild(discOf('hour',{ fill:OVERLAP.minuteHour, 'clip-path':inMinute })); + dateOf(inHour); face.appendChild(tickOf('hour')); face.appendChild(discOf('second',{ fill:DISC.second.color })); face.appendChild(discOf('second',{ fill:OVERLAP.minuteSecond, 'clip-path':inMinute })); face.appendChild(discOf('second',{ fill:OVERLAP.hourSecond, 'clip-path':inHour })); face.appendChild(discOf('second',{ fill:OVERLAP.minuteHourSecond, 'clip-path':inMinuteHour })); + dateOf(inSecond); face.appendChild(tickOf('second')); face.appendChild(el('circle',{ cx, cy, r:V*0.022, fill:dial.dot })); @@ -189,8 +200,9 @@ } document.getElementById('widget').setAttribute('aria-label', _a11yLabel(now)); - if(_showDate && _dateText){ - _dateText.textContent=_dateShortFmt.format(now); + if(_showDate && _dateTexts.length){ + const d=_dateShortFmt.format(now); + for(const n of _dateTexts) n.textContent=d; } } diff --git a/public/widgets/dns/index.html b/public/widgets/dns/index.html index 06d9c0e..db574e8 100644 --- a/public/widgets/dns/index.html +++ b/public/widgets/dns/index.html @@ -21,27 +21,13 @@ .widget.clickable { cursor:pointer; } .hero { display:flex; flex-direction:column; gap:2px; flex-shrink:0; } - .pct { font-size:clamp(26px,9cqw,36px); font-weight:700; color:#FF4245; line-height:1; letter-spacing:-0.02em; } + .pct { font-size:clamp(26px,9cqw,36px); font-weight:700; color:#FF4245; line-height:1; letter-spacing:-0.02em; transition: color 0.15s ease; } .sub { font-size:clamp(9px,3cqw,11px); font-weight:600; letter-spacing:0.09em; color:rgba(255,255,255,0.5); text-transform:uppercase; } - .flow { flex:1; min-height:0; margin-top:13px; position:relative; } + .flow { flex:1; min-height:0; margin-top:13px; position:relative; touch-action:pan-y; } .flow svg { width:100%; height:100%; display:block; overflow:visible; } .link { transition: opacity 0.15s ease; } - - .tip { - position:absolute; z-index:5; - background:rgba(20,20,22,0.96); - backdrop-filter:blur(8px); -webkit-backdrop-filter:blur(8px); - border-radius:8px; padding:6px 9px; - box-shadow:0 6px 18px rgba(0,0,0,0.55), inset 0 0 0 0.5px rgba(255,255,255,0.1); - display:flex; align-items:center; gap:6px; white-space:nowrap; - opacity:0; transform:translateY(2px); pointer-events:none; - transition: opacity 0.12s ease, transform 0.12s ease; - } - .tip.show { opacity:1; transform:translateY(0); } - .tip i { width:7px; height:7px; border-radius:50%; flex-shrink:0; } - .tip b { font-size:11px; font-weight:600; color:#fff; } - .tip s { font-size:11px; font-weight:500; color:rgba(255,255,255,0.6); text-decoration:none; } + @media (prefers-reduced-motion: reduce) { .pct, .link { transition:none; } } .msg { position:absolute; inset-inline:0; bottom:0; } @@ -56,7 +42,6 @@
    -
    @@ -82,7 +67,7 @@ const widgetEl = document.getElementById('widget'); const pctEl = document.getElementById('pct'); const svgEl = document.getElementById('svg'); -const tipEl = document.getElementById('tip'); +const subEl = document.getElementById('sub-label'); const msgEl = document.getElementById('msg'); const srEl = document.getElementById('sr'); @@ -126,11 +111,10 @@ const allowed = Math.max(0, total - blocked - cached); const pct = total > 0 ? Math.round(blocked / total * 100) : 0; - pctEl.textContent = formatNumber(pct / 100, { style: 'percent' }); - const model = { total, allowed, cached, blocked, pct }; _last = model; render(model); + showHeadline(); srEl.textContent = wt('ui.summary', '{pct}% of {total} queries blocked. Allowed {allowed}, cached {cached}, blocked {blocked}.') .replace('{pct}', formatNumber(pct)).replace('{total}', fmt(total)) @@ -138,79 +122,100 @@ } function constLink(x0,c0,x1,c1,t) { - const h=t/2, cx0=x0+(x1-x0)*0.5, cx1=x0+(x1-x0)*0.5; - return `M${x0},${c0-h} C${cx0},${c0-h} ${cx1},${c1-h} ${x1},${c1-h} ` - + `L${x1},${c1+h} C${cx1},${c1+h} ${cx0},${c0+h} ${x0},${c0+h} Z`; + const h=t/2, cx=x0+(x1-x0)*0.5; + return `M${x0},${c0-h} C${cx},${c0-h} ${cx},${c1-h} ${x1},${c1-h} ` + + `L${x1},${c1+h} C${cx},${c1+h} ${cx},${c0+h} ${x0},${c0+h} Z`; } +const W = 240, H = 120, NODE_W = 8, SRC_SHARE = 0.5; +let _segs = []; +let _sel = -1; + function render(m) { - const flow = document.getElementById('flow'); - const W = 240, H = 120; svgEl.setAttribute('viewBox', `0 0 ${W} ${H}`); - const sum = m.allowed + m.cached + m.blocked || 1; const segs = [ { key:'allowed', label:wt('ui.allowed','Allowed'), value:m.allowed, color:'#30D158' }, { key:'cached', label:wt('ui.cached','Cached'), value:m.cached, color:'#0091FF' }, { key:'blocked', label:wt('ui.blocked','Blocked'), value:m.blocked, color:'#FF4245' }, ].filter(s => s.value > 0); + _segs = segs; + if (_sel >= segs.length) _sel = -1; if (!segs.length) { svgEl.innerHTML=''; return; } - const nodeW = 9, pad = 2; - const nX = pad, nX2 = nX + nodeW; - const outX = W - pad - nodeW, outX2 = W - pad; + const sum = segs.reduce((a,s)=>a+s.value,0); + const srcH = H * SRC_SHARE; + const t = segs.map(s => Math.max(2, s.value / sum * srcH)); + const tSum = t.reduce((a,b)=>a+b,0); + const gap = segs.length > 1 ? (H - tSum) / (segs.length - 1) : 0; + const x0 = NODE_W, x1 = W - NODE_W; - const t = segs.map(s => Math.max(2, s.value / sum * H)); - const tScale = H / t.reduce((a,b)=>a+b,0); - const tt = t.map(v => v * tScale); - - let fy = 0; const fc = tt.map(th => { const c=fy+th/2; fy+=th; return c; }); - const outGap = segs.length > 1 ? 8 : 0; - const totalR = tt.reduce((a,b)=>a+b,0) + (segs.length-1)*outGap; - const rScale = (H - (segs.length-1)*outGap) / tt.reduce((a,b)=>a+b,0); - const rt = tt.map(v => v * rScale); - let ry = (H - (rt.reduce((a,b)=>a+b,0) + (segs.length-1)*outGap)) / 2; - const rc = rt.map(th => { const c=ry+th/2; ry+=th+outGap; return c; }); - - const meta = {}; + let sy = (H - tSum) / 2; + let ty = segs.length > 1 ? 0 : (H - tSum) / 2; let links = '', nodes = ''; segs.forEach((s,i) => { - links += ``; - nodes += ``; - meta[s.key] = { label:s.label, value:s.value, color:s.color, cy:rc[i] }; + s.sy = sy + t[i]/2; s.ty = ty + t[i]/2; s.t = t[i]; + links += ``; + nodes += ``; + sy += t[i]; ty += t[i] + gap; }); - nodes = `` + nodes; + nodes = `` + nodes; svgEl.innerHTML = links + nodes; + highlight(); +} + +function ribbonY(s, x) { + const u = Math.min(1, Math.max(0, (x - NODE_W) / (W - 2*NODE_W))); + return s.sy + (s.ty - s.sy) * (3*u*u - 2*u*u*u); +} - svgEl.querySelectorAll('.link').forEach(p => { - const onEnter = () => { - const mm = meta[p.dataset.k]; - svgEl.querySelectorAll('.link').forEach(q => q.style.opacity = q===p ? '1' : '0.3'); - tipEl.innerHTML = `${mm.label}${fmt(mm.value)}`; - tipEl.classList.add('show'); - const fh = document.getElementById('flow').clientHeight; - tipEl.style.top = Math.max(0, Math.min(fh - tipEl.offsetHeight, (mm.cy / H * fh) - tipEl.offsetHeight/2)) + 'px'; - tipEl.style.right = '4px'; tipEl.style.left = 'auto'; - }; - const onLeave = () => { - svgEl.querySelectorAll('.link').forEach(q => q.style.opacity = '0.9'); - tipEl.classList.remove('show'); - }; - p.style.cursor = _href ? 'pointer' : 'default'; - p.addEventListener('mouseenter', onEnter); - p.addEventListener('mouseleave', onLeave); - p.addEventListener('touchstart', e => { onEnter(); }, { passive:true }); +function highlight() { + svgEl.querySelectorAll('.link').forEach((p,i) => { + p.style.opacity = _sel < 0 || i === _sel ? '1' : '0.3'; }); } -document.addEventListener('touchend', e => { - if (!e.target.closest('.link')) { - svgEl.querySelectorAll('.link').forEach(q => q.style.opacity = '0.9'); - tipEl.classList.remove('show'); +function showHeadline() { + const s = _segs[_sel]; + if (s) { + pctEl.textContent = fmt(s.value); + pctEl.style.color = s.color; + subEl.textContent = s.label; + } else if (_last) { + pctEl.textContent = formatNumber(_last.pct / 100, { style: 'percent' }); + pctEl.style.color = ''; + subEl.textContent = wt('ui.queriesBlocked', 'Queries Blocked'); } -}, { passive:true }); +} + +function select(i) { + if (i === _sel) return; + _sel = i; + highlight(); + showHeadline(); +} + +function pick(e) { + if (!_segs.length) return; + const r = flowEl.getBoundingClientRect(); + if (!r.width || !r.height) return; + const x = (e.clientX - r.left) / r.width * W; + const y = (e.clientY - r.top) / r.height * H; + let best = 0, bestD = Infinity; + _segs.forEach((s,i) => { + const d = Math.abs(ribbonY(s, x) - y) - s.t/2; + if (d < bestD) { bestD = d; best = i; } + }); + select(best); +} + +const flowEl = document.getElementById('flow'); +flowEl.addEventListener('pointermove', pick); +flowEl.addEventListener('pointerdown', pick); +for (const type of ['pointerleave', 'pointercancel']) flowEl.addEventListener(type, () => select(-1)); +flowEl.addEventListener('pointerup', e => { if (e.pointerType !== 'mouse') select(-1); }); boot(); diff --git a/public/widgets/nowplaying/index.html b/public/widgets/nowplaying/index.html index 30be462..b525428 100644 --- a/public/widgets/nowplaying/index.html +++ b/public/widgets/nowplaying/index.html @@ -16,35 +16,38 @@ .widget.clickable{cursor:pointer} .widget.clickable:focus-visible{outline:2px solid #00D2E0;outline-offset:-3px} .widget::after{content:"";position:absolute;inset:0;border-radius:inherit;pointer-events:none; - box-shadow:inset 3px 0 0 rgba(0,0,0,.22),inset -3px 0 0 rgba(0,0,0,.22), - inset 0 1px 0 rgba(255,255,255,.06),inset 0 0 0 1px rgba(0,0,0,.3)} + box-shadow:inset 0 1px 0 rgba(255,255,255,.06),inset 0 0 0 1px rgba(0,0,0,.3)} .stage{position:absolute;inset:0} - .stage.outL{animation:outL .2s ease-in forwards} - .stage.outR{animation:outR .2s ease-in forwards} - .stage.inR{animation:inR .28s cubic-bezier(.22,.7,.3,1) forwards} - .stage.inL{animation:inL .28s cubic-bezier(.22,.7,.3,1) forwards} - @keyframes outL{to{transform:translateX(-114%);opacity:0}} - @keyframes outR{to{transform:translateX(114%);opacity:0}} - @keyframes inR{from{transform:translateX(114%);opacity:0}to{transform:translateX(0);opacity:1}} - @keyframes inL{from{transform:translateX(-114%);opacity:0}to{transform:translateX(0);opacity:1}} - - .sticker{position:absolute;left:14px;right:14px;top:11px;height:76px; - background:linear-gradient(180deg,#FFFFFF,#d2d2d2);border-radius:6px; + + .sticker{position:absolute;left:14px;right:14px;top:14px;height:73px; + background:linear-gradient(180deg,#FFFFFF,#d2d2d2);border-radius:14px; border:1px solid rgba(0,0,0,.18);box-shadow:0 1px 2px rgba(0,0,0,.35),inset 0 1px 0 rgba(255,255,255,.6); - padding:7px 10px;display:flex;flex-direction:column;gap:2px;overflow:hidden} + padding:8px 12px 22px;display:flex;flex-direction:column;gap:2px;overflow:hidden} .title{font-size:13px;font-weight:700;line-height:1.16;color:#1C1C1E; display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} - /* margin-top:auto seats the meta lines on the sticker's bottom edge. */ - .sub,.player{flex:none;font-size:9px;font-weight:600;line-height:1.12;letter-spacing:.02em; + .sub{flex:none;font-size:9px;font-weight:600;line-height:1.12;letter-spacing:.02em;color:#48484A;margin-top:auto; white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-transform:uppercase} - .sub{color:#48484A;margin-top:auto} - .player{color:#8E8E93} .idle .title{color:#636366;font-weight:600} - .sticker{--wt-cap-color:#48484A} - .np-cap{flex:none;margin-top:auto} + .sticker{--wt-cap-color:#1C1C1E} + .np-cap{flex:none;flex-wrap:wrap;row-gap:3px} + .sticker .np-cap b{font-size:12.5px;font-weight:600;line-height:1.2} + .sticker .np-cap svg{width:14px;height:14px} + .sticker .np-cap i{flex-basis:100%;padding-inline-start:19px;font-size:9px;font-weight:600; + letter-spacing:.03em;text-transform:uppercase;color:#8E8E93} /* .title sets display. Without this rule the hidden attribute does nothing. */ - .sticker [hidden]{display:none} + .sticker [hidden],.band [hidden]{display:none} + + .band{position:absolute;left:14px;right:14px;top:68px;height:19px;z-index:3; + display:flex;align-items:center;justify-content:space-between;gap:4px;padding-inline:12px 4px; + border-radius:0 0 14px 14px;box-shadow:inset 0 .5px 0 rgba(0,0,0,.2); + background:var(--band,#8E8E93);color:var(--band-ink,#FFFFFF)} + .err .band{background:#636366;color:#FFFFFF} + .player{min-width:0;font-size:8.5px;font-weight:700;line-height:1;letter-spacing:.06em; + white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-transform:uppercase} + .ctl{flex:none;display:flex;align-items:center} + .count{min-width:14px;font-size:8.5px;font-weight:600;line-height:1;text-align:center;opacity:.8; + font-variant-numeric:tabular-nums} .window{position:absolute;left:14px;right:14px;top:94px;bottom:14px; background:radial-gradient(135% 135% at 50% 20%,#636366,#48484A 58%,#48484A); @@ -65,41 +68,42 @@ display:flex;flex-direction:column;gap:6px;opacity:.5} .ticks i{display:block;width:12px;height:2px;background:#FFFFFF;border-radius:1px} - .arrow{position:absolute;top:93px;transform:translateY(-50%);width:24px;height:34px; - border:0;background:transparent;cursor:pointer;display:flex;align-items:center;justify-content:center; - color:#FFFFFF;opacity:.92;transition:opacity .15s;z-index:3} - .arrow svg{width:17px;height:17px;filter:drop-shadow(0 1px 1px rgba(0,0,0,.5))} - .arrow.l{inset-inline-start:0}.arrow.r{inset-inline-end:0} + .nav{width:19px;height:19px;border:0;border-radius:6px;background:transparent;color:inherit;cursor:pointer; + display:flex;align-items:center;justify-content:center} + .nav svg{width:10px;height:10px} + .nav:hover{background:rgba(128,128,128,.22)} + .nav:focus-visible{outline:2px solid currentColor;outline-offset:-2px} /* A chevron is a drawing: no logical property points it the other way. */ - [dir="rtl"] .arrow svg{transform:scaleX(-1)} - .arrow:disabled{opacity:.2;cursor:default} - .arrow:not(:disabled):hover{opacity:1} - .arrow:focus-visible{outline:2px solid #fff;outline-offset:1px;border-radius:4px;opacity:1} + [dir="rtl"] .nav svg{transform:scaleX(-1)} .sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);border:0} @media(prefers-reduced-motion:reduce){ .spinwrap .reel{animation:none!important} - .stage{animation:none!important} .tape{transition:none!important} }
    - - -
    +
    Nothing playing
    -
    +
    +
    + +
    @@ -126,7 +130,7 @@