diff --git a/public/js/admin-app-form.js b/public/js/admin-app-form.js index 3c05f5e..fc039ab 100644 --- a/public/js/admin-app-form.js +++ b/public/js/admin-app-form.js @@ -1,4 +1,4 @@ -import { clr, el, inp as inpById, q as qSel, qa, qi, tgt } from '/js/utils.js?v=c5766a9d'; +import { clr, el, focusFirst, inp as inpById, q as qSel, qa, qi, tgt } from '/js/utils.js?v=9a9bfb54'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { loadLocalIcons, resolveIcon, iconChain, cdnIconRef, splitIconRef } from '/js/icons.js?v=9c8c550c'; import { state } from '/js/admin-state.js?v=af772a1b'; @@ -22,10 +22,10 @@ import { reveal, setTogDisabled, swapContent, -} from '/js/admin-shared.js?v=a81b9cbe'; -import { createListbox } from '/js/listbox.js?v=9a8ae607'; +} from '/js/admin-shared.js?v=f5551857'; +import { createListbox } from '/js/listbox.js?v=30c1b9d1'; import { MAX_LABELS } from '/js/badge-logic.js?v=9e6d9d4b'; -import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=0d4d1038'; +import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=233683ad'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=f82486b2'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; import { iconSvg } from '/js/icon-set.js?v=34af798f'; @@ -65,7 +65,7 @@ function _wireFolderApps(apps, children) { const row = el('folder-apps-row'); if (!row) return; const box = createListbox({ - label: t('folder.appsInFolder'), + label: t('folder.addApps'), multiple: true, options: apps.map(a => ({ value: a.id, label: a.label || a.id })), value: children, @@ -115,14 +115,14 @@ export function buildAppForm(body, item) { ${ier('ie-url', t('app.url'), 'f-href', item?.href, t('app.urlPh'), 'url')} -

${t('app.icon')}

+

${t('app.icon')}

${state.siurl ? html`` : html`${(item?.label || '?')[0]?.toUpperCase() || '?'}`} - +
@@ -136,18 +136,18 @@ export function buildAppForm(body, item) {
${dockBlocked ? html`

${t('app.dockFull', { max: DOCK_MAX })}

` : ''} -

${t('app.badge')}

+

${t('app.badge')}

${t('app.healthCheck')}${tog('hc-en', hc.enabled, t('app.healthCheck'))}
-
${t('app.type')}
+
${t('app.type')}
${ier('ie-hc-con', t('app.container'), 'hc-con', hc.container, t('app.containerPh'))}
${ier('ie-hc-ping', t('app.pingUrl'), 'hc-ping', hc.pingUrl, t('app.pingUrlPh'), 'url')} -
+
@@ -165,7 +165,7 @@ export function buildAppForm(body, item) {
${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'))}
@@ -366,7 +366,7 @@ function renderActLabels(host) { hdr.dataset.idx = String(i); setHtml( hdr, - html`${t('app.labelN', { n: i + 1 })}`, + html`${t('app.labelN', { n: i + 1 })}`, ); const ctl = document.createElement('span'); ctl.className = 'albl-ctl'; @@ -388,6 +388,12 @@ function renderActLabels(host) { captureActLabels(); state.spaths.splice(i, 1); syncActMode(); + focusFirst( + actLabelBtns(i, '.grp-hdr-rm')[0], + actLabelBtns(i - 1, '.grp-hdr-rm')[0], + el('act-add-label'), + el('bfetch'), + ); }), ); hdr.appendChild(ctl); @@ -453,17 +459,29 @@ function addActLabel() { toast(t('app.noValuesLeft'), 'err'); return; } + const add = el('act-add-label'); + const hadFocus = document.activeElement === add; state.spaths.push(free.path); syncActMode(); + if (hadFocus && add?.hidden) focusFirst(actLabelBtns(state.spaths.length - 1, '.grp-hdr-rm')[0]); } function moveActLabel(from, delta) { const to = from + delta; if (to < 0 || to >= state.spaths.length) return; captureActLabels(); + const hadFocus = !!el('act-labels')?.contains(document.activeElement); const [p] = state.spaths.splice(from, 1); state.spaths.splice(to, 0, p); syncActMode(); + if (!hadFocus) return; + const [up, down] = actLabelBtns(to, '.albl-move'); + focusFirst(delta < 0 ? up : down, delta < 0 ? down : up); +} + +function actLabelBtns(i, sel) { + const hdr = el('act-labels')?.querySelector(`.albl-hdr[data-idx="${i}"]`); + return hdr ? qa(sel, hdr) : []; } /** Drag a label header to reorder. Keep the arrows: a pointer drag is @@ -523,6 +541,7 @@ const VARIANT_LABEL = { base: 'app.iconVariantBase', light: 'app.iconVariantLigh let ipList = []; let ipActive = -1; +let ipOutsideWired = false; function ipClose() { const rs = el('iprs'); @@ -727,9 +746,12 @@ function wireIcon() { }; } - document.addEventListener('click', e => { - if (!el('ipw')?.contains(/** @type {Node} */ (e.target))) ipClose(); - }); + if (!ipOutsideWired) { + ipOutsideWired = true; + document.addEventListener('click', e => { + if (!el('ipw')?.contains(/** @type {Node} */ (e.target))) ipClose(); + }); + } loadIconVariants(state.siurl); } @@ -914,6 +936,7 @@ function renderKvRows(host, rows, ph) { add.onclick = () => { rows.push({ key: '', value: '', secret: false, valueSet: false }); renderKvRows(host, rows, ph); + focusFirst(qa('.kv-row .kv-k', host).at(-1)); }; host.appendChild(add); } @@ -959,6 +982,8 @@ function kvRowEl(host, rows, row, ph) { const idx = rows.indexOf(row); if (idx >= 0) rows.splice(idx, 1); renderKvRows(host, rows, ph); + const dels = qa('.kv-del', host); + focusFirst(dels[idx], dels[idx - 1], qSel('.kv-add', host)); }; return rowEl; } diff --git a/public/js/admin-auth.js b/public/js/admin-auth.js index ab3c802..0acf19c 100644 --- a/public/js/admin-auth.js +++ b/public/js/admin-auth.js @@ -1,7 +1,7 @@ -import { apiGet, apiPost } from '/js/admin-shared.js?v=a81b9cbe'; +import { apiGet, apiPost } from '/js/admin-shared.js?v=f5551857'; import { t } from '/js/i18n.js?v=1f1ea9c1'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; -import { el, inp as inpById, qa } from '/js/utils.js?v=c5766a9d'; +import { el, inp as inpById, qa } from '/js/utils.js?v=9a9bfb54'; import { blockingScreenFor } from '/js/config-recovery.js?v=706fc9a7'; export async function checkAuth(onLogin) { @@ -29,12 +29,33 @@ export function requireLogin() { }); } +/** @type {Element[]} */ +let madeInert = []; +/** @type {HTMLElement|null} */ +let focusBefore = null; + +/** @param {HTMLElement|null} screen */ +function blockPageBehind(screen) { + if (madeInert.length) return; + focusBefore = /** @type {HTMLElement|null} */ (document.activeElement); + madeInert = [...document.body.children].filter(c => c !== screen && !c.hasAttribute('inert')); + madeInert.forEach(c => c.setAttribute('inert', '')); +} + +function unblockPageBehind() { + madeInert.forEach(c => c.removeAttribute('inert')); + madeInert = []; + if (focusBefore?.isConnected && focusBefore !== document.body) focusBefore.focus(); + focusBefore = null; +} + function showLoginScreen(onLogin) { const s = el('login-screen'); const btn = inpById('login-btn'); const pw = inpById('login-pw'); const err = el('login-err'); if (s) s.style.display = 'flex'; + blockPageBehind(s); async function doLogin() { if (btn) btn.disabled = true; @@ -42,6 +63,7 @@ function showLoginScreen(onLogin) { try { await apiPost('/api/auth/login', { password: pw?.value || '' }); if (s) s.style.display = 'none'; + unblockPageBehind(); onLogin?.(); } catch (e) { if (err) { diff --git a/public/js/admin-color-control.js b/public/js/admin-color-control.js index e06c800..86e2b2c 100644 --- a/public/js/admin-color-control.js +++ b/public/js/admin-color-control.js @@ -1,7 +1,7 @@ -import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=a81b9cbe'; +import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=f5551857'; import { t } from '/js/i18n.js?v=1f1ea9c1'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { qa, q } from '/js/utils.js?v=c5766a9d'; +import { qa, q } from '/js/utils.js?v=9a9bfb54'; import { iconSvg } from '/js/icon-set.js?v=34af798f'; import { HUE_NAMES, ROLE_NAMES, TILE_KEYWORDS, pageTheme, tileColor } from '/js/palette.js?v=3fb8ae43'; @@ -236,6 +236,12 @@ export function renderColorControl( }); const rb = q('.cc-rainbow', container); if (rb) rb.classList.toggle('on', showTune && (mode === 'color' || hues.includes(mode))); + const sw = qa('.cc-swatch', container); + const preset = sw.some(b => b !== rb && b.classList.contains('on')); + sw.forEach(b => + b.setAttribute('aria-pressed', String(b === rb ? mode === 'color' && !preset : b.classList.contains('on'))), + ); + rb?.setAttribute('aria-expanded', String(showTune)); reveal(tune, showTune, !_painted); if (!codeRv.closest('.editing')) { codeRv.textContent = mode === 'color' ? hex : kwName(mode); diff --git a/public/js/admin-drag.js b/public/js/admin-drag.js index f160c56..829c971 100644 --- a/public/js/admin-drag.js +++ b/public/js/admin-drag.js @@ -12,7 +12,7 @@ import { applyDrop, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=6b767e76'; import { state } from '/js/admin-state.js?v=af772a1b'; import { snapshotItems } from '/js/admin-save-logic.js?v=8389782f'; -import { qa } from '/js/utils.js?v=c5766a9d'; +import { qa } from '/js/utils.js?v=9a9bfb54'; /** @type {(before: unknown) => void} */ let _save = () => {}; diff --git a/public/js/admin-list.js b/public/js/admin-list.js index 6d15b30..306e55b 100644 --- a/public/js/admin-list.js +++ b/public/js/admin-list.js @@ -14,12 +14,12 @@ import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b'; import { snapshotItems } from '/js/admin-save-logic.js?v=8389782f'; import { reorderItems } from '/js/admin-logic.js?v=fc7f0836'; -import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=18af71d8'; -import { paintIcon } from '/js/admin-shared.js?v=a81b9cbe'; -import { clr, el, qa, setUserText } from '/js/utils.js?v=c5766a9d'; +import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=444456dd'; +import { paintIcon } from '/js/admin-shared.js?v=f5551857'; +import { clr, el, focusFirst, qa, setUserText } from '/js/utils.js?v=9a9bfb54'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { t } from '/js/i18n.js?v=1f1ea9c1'; -import { sizeLabel } from '/js/admin-widget-form.js?v=ef53bce9'; +import { sizeLabel } from '/js/admin-widget-form.js?v=53ceafd3'; import { widgetGlyph } from '/js/widget-glyphs.js?v=648cc374'; import { iconSvg } from '/js/icon-set.js?v=34af798f'; @@ -112,6 +112,7 @@ export function mkRow(item, idx, { indent = false, childIdx = null, folderId = n if (isFolderRow) { const collapsed = collapsedFolders.has(item.id); nm.setAttribute('type', 'button'); + nm.dataset.act = 'toggle'; nm.style.cssText = 'display:flex;align-items:center;gap:6px;'; nm.setAttribute('aria-expanded', String(!collapsed)); nm.setAttribute('aria-label', t(collapsed ? 'folder.expandAria' : 'folder.collapseAria', { name: item.label })); @@ -165,6 +166,7 @@ export function mkRow(item, idx, { indent = false, childIdx = null, folderId = n b.title = lbl; b.setAttribute('aria-label', lbl + ': ' + (item.label || item.id || t('type.app'))); b.textContent = dir < 0 ? '↑' : '↓'; + b.dataset.act = dir < 0 ? 'up' : 'down'; b.disabled = !can; b.onclick = () => moveRow(item, dir, { folderId, childIdx }); return b; @@ -174,6 +176,7 @@ export function mkRow(item, idx, { indent = false, childIdx = null, folderId = n const hb = document.createElement('button'); hb.className = 'btn bg sm'; hb.textContent = t(item.hidden ? 'common.show' : 'common.hide'); + hb.dataset.act = 'hide'; const lbl = t(item.hidden ? 'general.showSettingsAria' : 'general.hideSettingsAria'); hb.title = lbl; hb.setAttribute('aria-label', lbl); @@ -187,6 +190,7 @@ export function mkRow(item, idx, { indent = false, childIdx = null, folderId = n const ed = document.createElement('button'); ed.className = 'btn bg sm'; ed.textContent = t('common.edit'); + ed.dataset.act = 'edit'; ed.onclick = () => _page.openModal(idx); ac.append(ed); } @@ -195,8 +199,29 @@ export function mkRow(item, idx, { indent = false, childIdx = null, folderId = n return row; } +const FALLBACK = { up: ['up', 'down'], down: ['down', 'up'] }; + +/** @param {string|null|undefined} itemId @param {string} [act] @returns {boolean} */ +export function focusRow(itemId, act = 'edit') { + const row = qa('.drow', el('al')).find(r => r.dataset.itemId === itemId); + if (!row) return false; + const order = [...(FALLBACK[act] || [act]), 'edit', 'hide', 'toggle']; + return focusFirst(...order.map(a => row.querySelector(`[data-act="${a}"]`))); +} + export function render() { const l = el('al'); + const was = /** @type {HTMLElement|null} */ (document.activeElement); + const inList = !!was && !!l?.contains(was); + const row = /** @type {HTMLElement|null} */ (inList ? was.closest('.drow') : null); + const act = inList ? was.dataset.act : undefined; + const id = row ? row.dataset.itemId : was?.dataset.folderId; + draw(l); + if (act === 'add-to') focusFirst(qa('.fp-add', l).find(b => b.dataset.folderId === id)); + else if (act) focusRow(id, act); +} + +function draw(l) { const bar = el('al-filter'); const grp = el('al-grp'); if (bar) { @@ -248,6 +273,8 @@ export function render() { const addRow = document.createElement('button'); addRow.type = 'button'; addRow.className = 'fp-add'; + addRow.dataset.act = 'add-to'; + addRow.dataset.folderId = item.id; setHtml(addRow, html`+ ${t('folder.addAppToFolder')}`); addRow.onclick = () => _page.openFolderPicker(null, item.id); l.appendChild(addRow); diff --git a/public/js/admin-settings.js b/public/js/admin-settings.js index 081ee4c..7bc50b2 100644 --- a/public/js/admin-settings.js +++ b/public/js/admin-settings.js @@ -1,4 +1,4 @@ -import { toast, apiGet, apiPost, reveal, swapContent } from '/js/admin-shared.js?v=a81b9cbe'; +import { toast, apiGet, apiPost, reveal, swapContent } from '/js/admin-shared.js?v=f5551857'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; import { t } from '/js/i18n.js?v=1f1ea9c1'; import { @@ -10,9 +10,10 @@ import { BLOCK, } from '/js/admin-logic.js?v=fc7f0836'; import { confirmText, promptModal } from '/js/modal.js?v=6b0320bd'; -import { el, inp, setUserText } from '/js/utils.js?v=c5766a9d'; -import { renderColorControl } from '/js/admin-color-control.js?v=0d4d1038'; -import { BACKDROP } from '/js/background.js?v=5f478ebf'; +import { el, inp, setUserText } from '/js/utils.js?v=9a9bfb54'; +import { serialWrites } from '/js/admin-save-logic.js?v=8389782f'; +import { renderColorControl } from '/js/admin-color-control.js?v=233683ad'; +import { BACKDROP } from '/js/background.js?v=43a04bdb'; import { firstBadHost, hostnameOf, isLocalAddress, parseHostList } from '/js/host-names.js?v=842f96ca'; /* Mirrors the server's rule: auth cannot be switched on with no password. */ @@ -143,7 +144,12 @@ export function loadSettings(c) { const aw = inp('set-awake'); if (aw) { aw.checked = s.keepAwake === true; - aw.addEventListener('change', saveKeepAwake); + aw.addEventListener('change', e => saveSwitch(e, 'keepAwake')); + } + const ts = inp('set-type-search'); + if (ts) { + ts.checked = s.typeToSearch !== false; + ts.addEventListener('change', e => saveSwitch(e, 'typeToSearch')); } const bg = s.background || { type: 'unsplash', brightness: 0.62 }; _savedWallpaperUrl = bg.url || ''; @@ -332,37 +338,45 @@ export function showBgFields(type) { }); } /** @param {Event} [e] */ -async function saveLabels(e) { - const toggled = /** @type {HTMLInputElement|null} */ (e?.target ?? null); - const wasChecked = toggled ? toggled.checked : false; - try { - const c = await apiGet('/api/config'); - c.settings = c.settings || {}; - c.settings.showLabels = { desktop: inp('set-lbl-d')?.checked !== false, ios: inp('set-lbl-m')?.checked || false }; - await apiPost('/api/config', c); - toast(t('toast.saved')); - } catch (err) { - /* Put the box back on a failure, or it shows a setting the server was never - given. Assigning `checked` fires no event, so this does not loop. */ - if (toggled) toggled.checked = !wasChecked; - toast(t('toast.saveFailed', { err: err.message }), 'err'); - } +/* Switch saves run one after another. Overlapping saves read the same _rev, + so the second one is refused as stale and its switch is put back. */ +const switchSaves = serialWrites(); + +/** Save a switch's change, and put it back if the save fails and no later + click has moved it since. + @param {Event} e @param {(c: any) => void} apply */ +function saveSwitchChange(e, apply) { + const toggled = /** @type {HTMLInputElement} */ (e.target); + const value = toggled.checked; + return switchSaves.run(async () => { + try { + const c = await apiGet('/api/config'); + c.settings = c.settings || {}; + apply(c.settings); + await apiPost('/api/config', c); + toast(t('toast.saved')); + } catch (err) { + /* Assigning `checked` fires no event, so this does not loop. */ + if (toggled.checked === value) toggled.checked = !value; + toast(t('toast.saveFailed', { err: err.message }), 'err'); + } + }); } -async function saveKeepAwake(e) { - const toggled = /** @type {HTMLInputElement|null} */ (e?.target ?? null); - const wasChecked = toggled ? toggled.checked : false; - try { - const c = await apiGet('/api/config'); - c.settings = c.settings || {}; - c.settings.keepAwake = !!inp('set-awake')?.checked; - await apiPost('/api/config', c); - toast(t('toast.saved')); - } catch (err) { - /* Put the box back on a failure, or it shows a setting the server was never - given. Assigning `checked` fires no event, so this does not loop. */ - if (toggled) toggled.checked = !wasChecked; - toast(t('toast.saveFailed', { err: err.message }), 'err'); - } + +/** @param {Event} e */ +function saveLabels(e) { + return saveSwitchChange(e, settings => { + settings.showLabels = { desktop: inp('set-lbl-d')?.checked !== false, ios: inp('set-lbl-m')?.checked || false }; + }); +} + +/** A switch whose whole value is one boolean setting. + @param {Event} e @param {string} key */ +function saveSwitch(e, key) { + const value = /** @type {HTMLInputElement} */ (e.target).checked; + return saveSwitchChange(e, settings => { + settings[key] = value; + }); } async function saveWallpaper() { try { diff --git a/public/js/admin-shared.js b/public/js/admin-shared.js index eec202d..258a517 100644 --- a/public/js/admin-shared.js +++ b/public/js/admin-shared.js @@ -1,6 +1,6 @@ /* Stateless helpers shared by the admin modules. Mutable state stays out. */ import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=fc7f0836'; -import { el, q } from '/js/utils.js?v=c5766a9d'; +import { el, q } from '/js/utils.js?v=9a9bfb54'; import { t } from '/js/i18n.js?v=1f1ea9c1'; import { iconChain } from '/js/icons.js?v=9c8c550c'; import { iconSvg } from '/js/icon-set.js?v=34af798f'; @@ -237,6 +237,14 @@ export function initInlineEdit(rowId, inputId, { type = 'text', placeholder = '' wireInlineEdit(row, inp, { type, placeholder, onCommit }); } +/** Name a row's pencil after its label. Call again once the catalog loads. + @param {Element} row */ +export function nameEditPen(row) { + const labelEl = q('.rl', row); + const pen = q('.pe', row); + if (labelEl && pen) pen.setAttribute('aria-label', t('common.editNamed', { name: labelEl.textContent.trim() })); +} + /** The same row behaviour for elements a caller already holds. `row` needs an id. `fill: false` keeps the input's own value on open. `render` replaces how the committed value is shown. @@ -260,10 +268,7 @@ export function wireInlineEdit(row, inp, { type = 'text', placeholder = '', onCo if (labelEl) { if (!labelEl.id) labelEl.id = `${row.id}-rl`; inp.setAttribute('aria-labelledby', labelEl.id); - /* The pencil opens this row, so it is named after this row. Taking the name - from the label keeps the two in one language, and in step when either - changes. */ - pen.setAttribute('aria-label', t('common.editNamed', { name: labelEl.textContent.trim() })); + nameEditPen(row); } row.insertBefore(inp, pen); @@ -301,11 +306,13 @@ export function wireInlineEdit(row, inp, { type = 'text', placeholder = '', onCo if (e.key === 'Enter') { e.preventDefault(); commit(); + pen.focus(); } if (e.key === 'Escape') { e.preventDefault(); inp.value = before; row.classList.remove('editing'); + pen.focus(); } }, ); diff --git a/public/js/admin-widget-form.js b/public/js/admin-widget-form.js index 22ceeeb..ab46cd7 100644 --- a/public/js/admin-widget-form.js +++ b/public/js/admin-widget-form.js @@ -1,11 +1,11 @@ import { state } from '/js/admin-state.js?v=af772a1b'; -import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=a81b9cbe'; -import { createListbox } from '/js/listbox.js?v=9a8ae607'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=d95ac911'; +import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=f5551857'; +import { createListbox } from '/js/listbox.js?v=30c1b9d1'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=c458cf42'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=fc7f0836'; import { t } from '/js/i18n.js?v=1f1ea9c1'; -import { q, qi, qa } from '/js/utils.js?v=c5766a9d'; +import { focusFirst, q, qi, qa } from '/js/utils.js?v=9a9bfb54'; import { iconSvg } from '/js/icon-set.js?v=34af798f'; const SIZES_WITH_ICONS = new Set(['small', 'medium', 'large', 'xlarge']); @@ -94,6 +94,8 @@ function _renderWidgetForm(body) { if (!_sizeOpts.includes(state._wsize)) state._wsize = _sizeOpts.includes('medium') ? 'medium' : _sizeOpts[0]; const sizeHdr = document.createElement('p'); sizeHdr.className = 'grp-hdr'; + sizeHdr.setAttribute('role', 'heading'); + sizeHdr.setAttribute('aria-level', '2'); sizeHdr.textContent = t('widgetCfg.size'); body.appendChild(sizeHdr); const scard = document.createElement('div'); @@ -107,6 +109,7 @@ function _renderWidgetForm(body) { b.addEventListener('click', () => { state._wsize = b.dataset.size; _renderWidgetForm(body); + focusFirst(q(`.tile-opt[data-size="${state._wsize}"]`, body)); }), ); @@ -125,7 +128,12 @@ 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) swapContent(body, () => _renderWidgetForm(body)); + if (!_vf || key !== _vf || !d.isConnected) return; + const hadFocus = d.contains(document.activeElement); + swapContent(body, () => _renderWidgetForm(body)); + if (!hadFocus) return; + const row = qa('[data-field]', body).find(r => r.dataset.field === key); + focusFirst(row?.querySelector('input:checked'), row?.querySelector('button')); }, }); state._autoFormType = state._wtype; @@ -189,6 +197,8 @@ function _renderCustomConfig(body) { const o = state._iframeOpts || {}; const advHdr = document.createElement('p'); advHdr.className = 'grp-hdr'; + advHdr.setAttribute('role', 'heading'); + advHdr.setAttribute('aria-level', '2'); advHdr.textContent = t('widgetCfg.advanced'); body.appendChild(advHdr); const adv = document.createElement('div'); @@ -210,7 +220,7 @@ function _renderCustomConfig(body) { html`
${t('widgetCfg.referrerPolicy')}
${t('widgetCfg.allowFeaturePolicy')}${o.allow ? o.allow : 'autoplay; fullscreen'}
-
${t('widgetCfg.allowFullscreen')}
+
${t('widgetCfg.allowFullscreen')}
${t('widgetCfg.refreshInterval')} (ms)${o.refreshInterval ? o.refreshInterval : 'e.g. 2000'}
`, ); const refBox = createListbox({ diff --git a/public/js/admin.js b/public/js/admin.js index d0c5ad1..3440c32 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=7949fa07'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=9035d41b'; +import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=5703fb88'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=276069af'; import { recoveryShown } from '/js/config-recovery.js?v=706fc9a7'; -import { initList, render, syncFilterUI } from '/js/admin-list.js?v=f868b68c'; +import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=fc1559fb'; import { resolveAdminSection } from '/js/admin-logic.js?v=fc7f0836'; import { buildAppItem, @@ -18,22 +18,23 @@ import { settingsDirty, showBgFields, showWallpaperFile, -} from '/js/admin-settings.js?v=4354171b'; +} from '/js/admin-settings.js?v=88b89f37'; import { apiGet, apiPost, initInlineEdit, + nameEditPen, paintIcon, reveal, responseError, setReauthHandler, toast, -} from '/js/admin-shared.js?v=a81b9cbe'; +} from '/js/admin-shared.js?v=f5551857'; import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=ef53bce9'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=53ceafd3'; 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=9a8ae607'; +import { createListbox } from '/js/listbox.js?v=30c1b9d1'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=1f1ea9c1'; import { loadLocalIcons } from '/js/icons.js?v=9c8c550c'; @@ -42,11 +43,12 @@ import { clearSkipTls, convert, detectSource, + ImportTooLargeError, insecureApps, NOTE, parseErrorsAsSkipped, SKIP, -} from '/js/import-foreign.js?v=2aa3bf02'; +} from '/js/import-foreign.js?v=dda5296a'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607'; import { confirmModal, confirmText, openModal as openDialog, promptModal } from '/js/modal.js?v=6b0320bd'; import { @@ -58,8 +60,8 @@ import { watchSystemTheme, writeMode, } from '/js/theme.js?v=eeafa4b5'; -import { el, inp, q, qa, clr, setUserText, tgt } from '/js/utils.js?v=c5766a9d'; -import { applyBackground, resolveBackground } from '/js/background.js?v=5f478ebf'; +import { el, focusFirst, inp, q, qa, clr, setUserText, storeGet, storeSet, tgt } from '/js/utils.js?v=9a9bfb54'; +import { applyBackground, resolveBackground } from '/js/background.js?v=43a04bdb'; import { parseYamlTolerant, YamlLiteError } from '/js/yaml-lite.js?v=6ebb564c'; ensureSprite(); @@ -84,6 +86,7 @@ async function load() { state.items = c.items || []; state._settings = c.settings || {}; await initI18n(c.settings?.language || 'en'); + qa('.ie-row').forEach(nameEditPen); document.title = t('nav.pageTitle'); initVersion(); syncPickerLabels(); @@ -193,9 +196,11 @@ async function saveOrRevert(before) { } } -function showListView() { +/** @param {string|null} [focusId] the item whose row takes focus */ +function showListView(focusId = null) { el('dash-list-view').classList.remove('d-none'); el('dash-edit-view').classList.add('d-none'); + if (!focusRow(focusId)) focusFirst(el('btn-add')); } function scrollSettingsTop() { scrollTo(0, 0); @@ -230,7 +235,7 @@ function buildAddNewCard() { b.onclick = () => { if (state.ctype === kind) return; state.ctype = kind; - _renderEditBody(); + _renderEditBody(kind); }; grpTiles.appendChild(b); }); @@ -240,7 +245,8 @@ function buildAddNewCard() { } /* Prepended after the builder runs, so the builder's reset cannot wipe it. */ -function _renderEditBody() { +/** @param {string} [focusKind] the type tile that keeps focus */ +function _renderEditBody(focusKind) { const body = el('ev-body'); body.replaceChildren(); if (state.ctype === 'widget') buildWidgetForm(body, state._evItem); @@ -248,9 +254,9 @@ function _renderEditBody() { else buildAppForm(body, state._evItem); if (!state._evIsEdit) body.insertBefore(buildAddNewCard(), body.firstChild); setTimeout(() => { - try { - q('input,select,textarea', body)?.focus(); - } catch {} + if (el('dash-edit-view').contains(document.activeElement)) return; + const tile = focusKind ? qa('.tile-opt', body).find(b => b.dataset.ctype === focusKind) : null; + focusFirst(tile, ...qa('input,select,textarea,button', body)); }, 50); } @@ -356,8 +362,8 @@ async function _evDelete(item, idx) { } el('btn-add').onclick = () => openModal(null); -function closeModal() { - showListView(); +function closeModal(focusId = state.eid) { + showListView(focusId); state.eid = null; state._wtype = 'custom'; state._wsize = 'medium'; @@ -615,7 +621,7 @@ async function doSave(orig) { const { replaced } = upsertItem(state.items, state.eid, item); /* The editor stays open on a failed write, with the form intact. */ if (!(await saveOrRevert(before))) return; - closeModal(); + closeModal(item.id); toast(t(replaced ? 'toast.updated' : 'toast.added')); } catch (e) { toast(t('toast.error', { err: e.message }), 'err'); @@ -637,14 +643,19 @@ function initNav() { qa('.sec', document).forEach(s => { s.hidden = s.id !== 'sec-' + id; }); - links.forEach(l => l.classList.toggle('active', l.dataset.sec === id)); + links.forEach(l => { + const on = l.dataset.sec === id; + l.classList.toggle('active', on); + if (on) l.setAttribute('aria-current', 'page'); + else l.removeAttribute('aria-current'); + }); syncGlideSelect(); - localStorage.setItem(STORE, id); + storeSet(STORE, id); if (picked) scrollSettingsTop(); } links.forEach(l => l.addEventListener('click', () => show(l.dataset.sec, true))); addEventListener('hashchange', () => show(location.hash.slice(1), true)); - show(location.hash.slice(1) || localStorage.getItem(STORE)); + show(location.hash.slice(1) || storeGet(STORE)); initGlideSelect(); } @@ -763,7 +774,7 @@ function initBgType() { }; const box = createListbox({ id: 'bg-type', - label: t('appearance.wallpaperSource'), + label: t('appearance.source'), options: [ { value: 'unsplash', label: t('appearance.sourceUnsplash') }, { value: 'url', label: t('appearance.sourceUrl') }, @@ -774,7 +785,7 @@ function initBgType() { }); slot.appendChild(box.el); relabel(() => { - box.setLabel(t('appearance.wallpaperSource')); + box.setLabel(t('appearance.source')); box.setOptions( [ { value: 'unsplash', label: t('appearance.sourceUnsplash') }, @@ -1140,7 +1151,13 @@ el('imp-foreign').onchange = async e => { } const kind = detectSource(doc); if (!kind) throw new Error(t('toast.importUnknownFormat', { file: file.name })); - const out = convert(kind, doc, taken, t('importForeign.untitledFolder')); + let out; + try { + out = convert(kind, doc, taken, t('importForeign.untitledFolder')); + } catch (err) { + if (err instanceof ImportTooLargeError) throw new Error(t('toast.importTooLarge', { file: file.name })); + throw err; + } items.push(...out.items); skipped.push(...parseErrorsAsSkipped(parseErrors, file.name), ...out.skipped); notes.push(...out.notes); diff --git a/public/js/background.js b/public/js/background.js index cba313f..dfa46b7 100644 --- a/public/js/background.js +++ b/public/js/background.js @@ -1,5 +1,5 @@ // @ts-check -import { cssColor, sanitizeCssUrl } from '/js/utils.js?v=c5766a9d'; +import { cssColor, sanitizeCssUrl } from '/js/utils.js?v=9a9bfb54'; import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6'; export const BACKDROP = '#0d1117'; diff --git a/public/js/dashboard.js b/public/js/dashboard.js index 7cf16bb..4e572a7 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -22,11 +22,13 @@ import { qa, qi, setUserText, + storeGet, + storeSet, teardownWidgets, titleWhenTruncated, -} from '/js/utils.js?v=c5766a9d'; +} from '/js/utils.js?v=9a9bfb54'; import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; -import { initSpotlight } from '/js/spotlight.js?v=75f7fd04'; +import { initSpotlight } from '/js/spotlight.js?v=17a182a1'; import { html, setHtml, raw } from '/js/html.js?v=c71f8903'; import { initI18n, t, currentLang } from '/js/i18n.js?v=1f1ea9c1'; import { blockingScreenFor, showBlockingScreen } from '/js/config-recovery.js?v=706fc9a7'; @@ -41,7 +43,7 @@ import { buildMobile, resetMobileChrome, mkFolderGlyph, -} from '/js/ui.js?v=d9417e1e'; +} from '/js/ui.js?v=c64101c8'; import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=9e6d9d4b'; import { formatNumber } from '/js/format-number.js?v=4a5ccef4'; import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=aa52b1a3'; @@ -53,7 +55,7 @@ import { landingAfterSetup, restorePage, } from '/js/dashboard-logic.js?v=0d519f8b'; -import { applyBackground, BACKDROP, resolveBackground } from '/js/background.js?v=5f478ebf'; +import { applyBackground, BACKDROP, resolveBackground } from '/js/background.js?v=43a04bdb'; import { repeatJittered } from '/js/jitter.js?v=087a1fcf'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607'; import { startWakeLock } from '/js/wake-lock.js?v=6b9591cf'; @@ -126,22 +128,6 @@ const BOOT_TIMEOUT_MS = 15000; const PAGE_STORE = 'dash_page'; -/** @param {string} key @returns {string|null} */ -function storeGet(key) { - try { - return localStorage.getItem(key); - } catch { - return null; - } -} - -/** @param {string} key @param {string} value */ -function storeSet(key, value) { - try { - localStorage.setItem(key, value); - } catch {} -} - let items = [], pg = 0, totalPages = 0, @@ -207,6 +193,11 @@ function bupd(id) { const sig = badgeSignature({ cls, txt, unit, bg, aria, color, nextColor, rows }); els.forEach(badge => { + /* Before the signature check. A badge first paints before its tile holds + it, and an unchanged repaint would never name the tile. */ + const tile = /** @type {HTMLElement|null} */ (badge.closest('a, button, [role="button"]')); + const tileName = tile?.dataset.tileName; + if (tile && tileName) tile.setAttribute('aria-label', aria ? `${tileName}, ${aria}` : tileName); if (BSIG.get(badge) === sig) return; BSIG.set(badge, sig); badge.className = cls; @@ -229,9 +220,6 @@ function bupd(id) { badge.setAttribute('aria-hidden', 'true'); badge.removeAttribute('role'); badge.removeAttribute('aria-label'); - const tile = /** @type {HTMLElement|null} */ (badge.closest('a, button, [role="button"]')); - const tileName = tile?.dataset.tileName; - if (tile && tileName) tile.setAttribute('aria-label', aria ? `${tileName}, ${aria}` : tileName); /* Never the `background` shorthand. It resets background-clip, and the pill behind is painted from this same value. */ if (bg) badge.style.setProperty('--badge-bg', bg); @@ -840,6 +828,7 @@ async function boot() { initSpotlight({ getItems: () => items, isMob: () => MOB, + typeToOpen: () => S.typeToSearch !== false, CB, iconChain, openFolderDesktop, diff --git a/public/js/import-foreign.js b/public/js/import-foreign.js index 1336e17..6f11b37 100644 --- a/public/js/import-foreign.js +++ b/public/js/import-foreign.js @@ -16,6 +16,12 @@ export const SKIP = Object.freeze({ UNPARSABLE: 'unparsable', }); +/* An alias is the same object as its anchor, so a short file can list millions + of entries and freeze the tab. Real configs hold a few hundred. */ +export const MAX_IMPORT_ENTRIES = 5000; + +export class ImportTooLargeError extends Error {} + export const NOTE = Object.freeze({ ICON_DROPPED: 'icon-dropped', PING_DROPPED: 'ping-dropped', @@ -128,8 +134,12 @@ export function convertIcon(raw) { @param {Iterable} takenIds */ function collector(takenIds) { const taken = takenIds instanceof Set ? takenIds : new Set(takenIds || []); + let entries = 0; return { taken, + visit() { + if (++entries > MAX_IMPORT_ENTRIES) throw new ImportTooLargeError(); + }, /** @type {any[]} */ items: [], /** @type {Array<{ reason: string, name: string, group: string, detail?: string }>} */ skipped: [], /** @type {Array<{ code: string, name: string, group: string, detail?: string }>} */ notes: [], @@ -219,6 +229,7 @@ export function convertHomepageServices(doc, takenIds = []) { const at = col.items.length; const children = []; for (const entry of Array.isArray(entries) ? entries : []) { + col.visit(); const e = soleEntry(entry); if (!e) { /* Not a `{ name: fields }` wrapper, so there is no service to read. */ @@ -289,6 +300,7 @@ export function convertHomepageBookmarks(doc, takenIds = []) { const at = col.items.length; const children = []; for (const entry of entries) { + col.visit(); const e = soleEntry(entry); if (!e) { col.skip(SKIP.UNREADABLE, '', groupLabel); @@ -338,6 +350,7 @@ export function convertDashy(doc, takenIds = [], untitledFolder = 'Imported') { const children = []; const addDashyItem = (raw, viaSubItem) => { + col.visit(); if (!isMap(raw)) { col.skip(SKIP.UNREADABLE, '', groupLabel); return; diff --git a/public/js/listbox.js b/public/js/listbox.js index c9f80df..b08eba2 100644 --- a/public/js/listbox.js +++ b/public/js/listbox.js @@ -9,7 +9,7 @@ import { nextActiveIndex } from '/js/admin-logic.js?v=fc7f0836'; 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=c5766a9d'; +import { qa } from '/js/utils.js?v=9a9bfb54'; import { iconSvg } from '/js/icon-set.js?v=34af798f'; const CHEV = iconSvg('chevrons', 22, 'solid', 'dd-chev'); @@ -90,11 +90,12 @@ export function createListbox( dd.className = 'row-dd'; setHtml( dd, - html` + html`
    `, ); const btn = /** @type {HTMLElement} */ (dd.querySelector('.row-dd-btn')); const text = /** @type {HTMLElement} */ (dd.querySelector('.row-dd-text')); + const name = /** @type {HTMLElement} */ (dd.querySelector('.visually-hidden')); const list = /** @type {HTMLElement} */ (dd.querySelector('.row-dd-list')); pruneDetached(); list.remove(); @@ -235,6 +236,7 @@ export function createListbox( close({ focusBtn: true }); } onChange?.(getValue()); + if (!btn.isConnected && btn.id) document.getElementById(btn.id)?.focus(); } let typed = ''; @@ -339,6 +341,7 @@ export function createListbox( }, setLabel(next) { list.setAttribute('aria-label', next); + name.textContent = next; }, close: () => close(), }; diff --git a/public/js/spotlight.js b/public/js/spotlight.js index 75a3c0f..accf539 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=c5766a9d'; +import { mk, clr, el, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=9a9bfb54'; import { t } from '/js/i18n.js?v=1f1ea9c1'; /* Attached to the window so a re-open can undo the previous one. */ @@ -9,7 +9,16 @@ const ACTIVATES_ON_SPACE = 'a[href], button, input, select, textarea, summary, [ /* `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, folderGlyph }) { +export function initSpotlight({ + getItems, + isMob, + typeToOpen, + CB, + iconChain, + openFolderDesktop, + openFolderMobile, + folderGlyph, +}) { const MOB = () => isMob(); const ov = /** @type {HTMLDialogElement} */ (el('spot')); const inp = inpById('sin'); @@ -306,7 +315,7 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto document.addEventListener( 'keydown', e => { - if (ov.classList.contains('on')) return; + if (ov.classList.contains('on') || !typeToOpen()) return; if (e.key === 'Escape' || e.key.length !== 1 || e.metaKey || e.ctrlKey || e.altKey) return; if (e.key === ' ' && e.target instanceof Element && e.target.closest(ACTIVATES_ON_SPACE)) return; e.stopImmediatePropagation(); diff --git a/public/js/ui.js b/public/js/ui.js index 569ed73..9c42d77 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -14,7 +14,7 @@ import { q, qa, setUserText, -} from '/js/utils.js?v=c5766a9d'; +} from '/js/utils.js?v=9a9bfb54'; import { t, currentLang } from '/js/i18n.js?v=1f1ea9c1'; import { toneForColor } from '/js/label-contrast.js?v=c1ac6fb8'; import { mobileMetrics, gridColumnWidth, gridCellCount } from '/js/mobile-metrics.js?v=349f4300'; @@ -78,7 +78,7 @@ function mkMiniIcon(child, pointerEvents) { if (child.iconUrl) { const srcs = iconChain(child.iconUrl); if (srcs.length) { - const img = mk('img', { loading: 'lazy', draggable: false }); + const img = mk('img', { alt: '', loading: 'lazy', draggable: false }); img.className = 'folder-mini-img'; if (pointerEvents === 'none') img.style.pointerEvents = 'none'; let step = 0; @@ -203,6 +203,7 @@ export function openFolderDesktop(folder) { a.className = 'folder-icon-link'; a.style.width = iw + 'px'; a.setAttribute('aria-label', child.label || child.id); + a.dataset.tileName = child.label || child.id; if (!showLabel) a.title = child.label || child.id; a.onclick = () => { closeDesk(); @@ -457,6 +458,7 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) { const a = mk('a', { href: child.href, target: '_blank', rel: 'noreferrer noopener' }); a.className = 'dyn-fold-anchor'; a.setAttribute('aria-label', child.label || child.id); + a.dataset.tileName = child.label || child.id; a.onclick = e => { e.stopPropagation(); closeMob(); diff --git a/public/js/utils.js b/public/js/utils.js index 1228e6f..afabaa5 100644 --- a/public/js/utils.js +++ b/public/js/utils.js @@ -11,6 +11,23 @@ export const mk = (tag, a = {}) => { Object.assign(e, a); return e; }; +/* Site storage blocked by the browser throws on every access. */ +/** @param {string} key @returns {string|null} */ +export function storeGet(key) { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +/** @param {string} key @param {string} value */ +export function storeSet(key, value) { + try { + localStorage.setItem(key, value); + } catch {} +} + /* Every colour an item renders with passes through here, and the value can arrive in an imported config. It is assigned to a background, where a CSS url() fetches from whatever host it names. */ @@ -120,6 +137,18 @@ export const qa = (sel, root = document) => /** @type {HTMLElement[]} */ ([...ro @param {Event} e @returns {HTMLInputElement} */ export const tgt = e => /** @type {HTMLInputElement} */ (e.target); +/** Focus the first candidate that is attached, enabled and rendered. + @param {...(Element|null|undefined)} els @returns {boolean} whether one took focus */ +export function focusFirst(...els) { + for (const e of els) { + const h = /** @type {HTMLElement & { disabled?: boolean }} */ (e); + if (!h?.isConnected || h.disabled || h.hidden || !h.getClientRects().length) continue; + h.focus(); + if (document.activeElement === h) return true; + } + return false; +} + export const ICON_R = 0.26; /* breg is passed in to avoid a circular import. */ diff --git a/public/js/widget-config-form.js b/public/js/widget-config-form.js index 469c9bd..6fea324 100644 --- a/public/js/widget-config-form.js +++ b/public/js/widget-config-form.js @@ -3,9 +3,9 @@ import { t } from '/js/i18n.js?v=1f1ea9c1'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=a81b9cbe'; -import { createListbox } from '/js/listbox.js?v=9a8ae607'; -import { renderColorControl } from '/js/admin-color-control.js?v=0d4d1038'; +import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=f5551857'; +import { createListbox } from '/js/listbox.js?v=30c1b9d1'; +import { renderColorControl } from '/js/admin-color-control.js?v=233683ad'; import { seedCarried, applyOptionSet, @@ -15,7 +15,7 @@ import { visibleFieldFlags, } from '/js/admin-logic.js?v=fc7f0836'; import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=f82486b2'; -import { qi } from '/js/utils.js?v=c5766a9d'; +import { qi } from '/js/utils.js?v=9a9bfb54'; const PE = ''; @@ -174,6 +174,8 @@ function _picklist(field, value, ctx, size) { wrap.className = 'wcf-group'; const hdr = document.createElement('p'); hdr.className = 'grp-hdr'; + hdr.setAttribute('role', 'heading'); + hdr.setAttribute('aria-level', '2'); hdr.textContent = field.label; wrap.appendChild(hdr); const card = document.createElement('div'); @@ -184,6 +186,7 @@ function _picklist(field, value, ctx, size) { fr.className = 'row'; const status = document.createElement('span'); status.className = 'row-status'; + status.setAttribute('role', 'status'); status.textContent = field.hint || ''; const btn = document.createElement('button'); btn.type = 'button'; @@ -298,6 +301,7 @@ function _select(field, value, ctx, config = {}) { fr.className = 'row'; const status = document.createElement('span'); status.className = 'row-status'; + status.setAttribute('role', 'status'); status.textContent = field.hint || ''; const btn = document.createElement('button'); btn.type = 'button'; @@ -341,6 +345,7 @@ function _pills(field, value) { const wrap = document.createElement('div'); const row = document.createElement('div'); row.className = 'row'; + row.dataset.field = field.key; const opts = Array.isArray(field.options) ? field.options : []; let sel = value != null ? value : field.default != null ? field.default : opts[0] ? opts[0].value : ''; const name = uniqueId('wcf-' + field.key); @@ -379,7 +384,9 @@ function _pills(field, value) { wrap.dispatchEvent(new Event('change')); }, }); + const hadFocus = group.contains(document.activeElement); group.replaceWith(box.el); + if (hadFocus) box.el.querySelector('button')?.focus(); }); ro.observe(row); } @@ -530,6 +537,8 @@ function _object(field, value, ctx) { const wrap = document.createElement('div'); const hdr = document.createElement('p'); hdr.className = 'grp-hdr'; + hdr.setAttribute('role', 'heading'); + hdr.setAttribute('aria-level', '2'); hdr.textContent = field.label; wrap.appendChild(hdr); const card = document.createElement('div'); @@ -601,7 +610,7 @@ function _group(field, rows, size, ctx) { data.forEach((rowData, idx) => { const hdr = document.createElement('p'); hdr.className = 'grp-hdr grp-hdr-row'; - setHtml(hdr, html`${field.label} ${idx + 1}`); + setHtml(hdr, html`${field.label} ${idx + 1}`); const rm = document.createElement('button'); rm.type = 'button'; rm.className = 'grp-hdr-rm'; diff --git a/public/js/widget-toolbox.js b/public/js/widget-toolbox.js index e1c5caa..1265775 100644 --- a/public/js/widget-toolbox.js +++ b/public/js/widget-toolbox.js @@ -91,6 +91,42 @@ export function openUrl(href) { } } +const _links = new WeakMap(); + +/** Make `el` a link to `href` for mouse and keyboard: role link, a tab stop, + click and Enter. Call again to change the link; an empty or unsafe `href` + removes it and restores the element's own role. Keep buttons and other + controls out of `el`: a link must not contain them. + @param {HTMLElement} el @param {string} href @returns {boolean} whether `el` is now a link */ +export function linkTo(el, href) { + const on = !!href && isSafeLinkUrl(href); + let link = _links.get(el); + if (!link) { + if (!on) return false; + link = { href: '', role: el.getAttribute('role') }; + _links.set(el, link); + el.addEventListener('click', () => { + if (link.href) openUrl(link.href); + }); + el.addEventListener('keydown', e => { + if (!link.href || e.key !== 'Enter' || e.target !== el) return; + e.preventDefault(); + openUrl(link.href); + }); + } + link.href = on ? href : ''; + el.classList.toggle('clickable', on); + if (on) { + el.setAttribute('role', 'link'); + el.tabIndex = 0; + } else { + if (link.role) el.setAttribute('role', link.role); + else el.removeAttribute('role'); + el.removeAttribute('tabindex'); + } + return on; +} + export async function getConfig() { const id = widgetId(); const r = await fetch(`/api/widget-config/${encodeURIComponent(id)}`, { cache: 'no-store' }); diff --git a/public/widgets/backup/backup.html b/public/widgets/backup/backup.html index 2790953..7664124 100644 --- a/public/widgets/backup/backup.html +++ b/public/widgets/backup/backup.html @@ -32,6 +32,7 @@ align-self: stretch; } .card-wrap.clickable { cursor: pointer; } + .card-wrap.clickable:focus-visible { outline: 2px solid #0091FF; outline-offset: 2px; filter: none; } .card-wrap.inactive { opacity: 0.38; pointer-events: none; } /* Back card */ @@ -93,6 +94,7 @@ display: block; } .card-wrap:hover .card-front, + .card-wrap:focus-visible .card-front, .card-wrap:active .card-front { opacity: 0; } .card-front-name-zone { @@ -121,7 +123,7 @@