+
${t('app.iconVariant')}
+
+ ${variants.map(
+ v =>
+ html`${t(VARIANT_LABEL[v.key] || 'app.iconVariantBase')} `,
+ )}
+
+
`,
+ );
+ slot.querySelectorAll('input').forEach(r => {
+ r.onchange = () => {
+ if (r.checked) setIconRef(r.value);
+ };
+ });
+}
+
+/* Only the catalogue knows which files an icon has. */
+async function loadIconVariants(ref) {
+ if (!ref || ref.startsWith('http://') || ref.startsWith('https://') || ref.includes('/')) return;
+ try {
+ const d = await ag(`/api/icons/variants?ref=${encodeURIComponent(ref)}`);
+ if (inpById('ip-in')?.value !== ref) return;
+ const variants = d.variants || [];
+ /* The catalogue lists this name but holds no file under it. */
+ if (variants.length && !variants.some(v => v.ref === ref)) setIconRef(variants[0].ref);
+ renderIconVariants(variants);
+ } catch {}
+}
+
function wireIcon() {
const inp = inpById('ip-in'),
rs = el('iprs');
if (!inp) return;
- let t;
+ let t0;
+ /* Each keystroke starts a search and the slow ones finish last. The token
+ stops a stale result replacing a newer one. */
+ let run = 0;
+ const search = async v => {
+ const mine = ++run;
+ const q = v.replace(/\.(svg|png|ico)$/i, '');
+ try {
+ const d = await ag(`/api/icons/search?q=${encodeURIComponent(q)}`);
+ if (mine !== run) return;
+ showIPRes(d.results || [], v);
+ } catch {
+ if (mine !== run) return;
+ /* No catalogue to confirm against, so the typed name is all that is left. */
+ updPrev();
+ ipMessage(t('app.iconSearchFailed'), t('app.iconTryUpload'));
+ ipSay(t('app.iconSearchFailed'));
+ }
+ };
inp.oninput = () => {
const v = inp.value.trim();
- if (v.startsWith('http://') || v.startsWith('https://')) {
- state.siurl = v;
+ clearTimeout(t0);
+ state.siurl = v;
+ /* Nothing to look up: empty, an address, or a file already on this server. */
+ if (!v || v.startsWith('http://') || v.startsWith('https://') || v.includes('/') || resolveIcon(v)) {
+ run++;
updPrev();
- rs.classList.remove('open');
+ ipClose();
+ renderIconVariants([]);
return;
}
- if (v && !v.includes('/')) {
- state.siurl = v;
- updPrev();
- clearTimeout(t);
- t = setTimeout(async () => {
- const q = v.replace(/\.(svg|png)$/i, '');
- try {
- const d = await ag(`/api/icons/search?q=${encodeURIComponent(q)}`);
- showIPRes(d.results || [], v);
- } catch {
- rs.classList.remove('open');
- }
- }, 300);
- return;
+ /* A half-typed name is never requested. Each miss is held for a day in a
+ cache of 300, so typing evicts icons that do exist. */
+ ipMessage(t('app.iconSearching'));
+ t0 = setTimeout(() => search(v), 300);
+ };
+
+ inp.onkeydown = e => {
+ const open = rs?.classList.contains('open');
+ /* Arrows only. nextActiveIndex also answers Home and End, which belong to
+ the caret while focus is in a text field. */
+ if (ipList.length && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
+ const moved = nextActiveIndex(e.key, ipActive, ipList.length);
+ if (moved != null) {
+ e.preventDefault();
+ return void ipSetActive(moved);
+ }
}
- clearTimeout(t);
- if (!v) {
- rs.classList.remove('open');
- return;
+ if (e.key === 'Enter' && ipActive >= 0) {
+ e.preventDefault();
+ return void ipChoose(ipActive);
}
- t = setTimeout(async () => {
- try {
- const d = await ag(`/api/icons/search?q=${encodeURIComponent(v)}`);
- showIPRes(d.results || [], v);
- } catch {
- rs.classList.remove('open');
- }
- }, 300);
+ if (e.key === 'Escape' && open) {
+ e.preventDefault();
+ e.stopPropagation();
+ ipClose();
+ }
+ if (e.key === 'Tab' && open) ipClose();
};
const upInput = inpById('ip-upload');
@@ -570,6 +717,8 @@ function wireIcon() {
state.siurl = d.filename;
const ipIn = inpById('ip-in');
if (ipIn) ipIn.value = d.filename;
+ renderIconVariants([]);
+ ipClose();
updPrev();
toast(t('toast.uploaded', { name: d.filename }));
} catch (e) {
@@ -582,76 +731,93 @@ function wireIcon() {
}
document.addEventListener('click', e => {
- if (!el('ipw')?.contains(/** @type {Node} */ (e.target))) rs?.classList.remove('open');
+ if (!el('ipw')?.contains(/** @type {Node} */ (e.target))) ipClose();
});
+ loadIconVariants(state.siurl);
+}
+
+function ipRow(i, iconRef, name, source, urls = [], variants = []) {
+ const r = document.createElement('button');
+ r.type = 'button';
+ r.className = 'ipr';
+ r.id = `ipr-${i}`;
+ r.setAttribute('role', 'option');
+ r.setAttribute('aria-selected', 'false');
+ const img = document.createElement('img');
+ img.alt = '';
+ /* Ordered by the catalogue, whose first entry is the file it actually holds. */
+ const srcs = [...urls, ...variants.flatMap(v => v.urls || [])].filter(Boolean);
+ if (!srcs.length) srcs.push(...iconChain(iconRef));
+ let step = 0;
+ img.src = srcs[0] || '';
+ img.onerror = () => {
+ step++;
+ if (step < srcs.length) img.src = srcs[step];
+ else {
+ img.onerror = null;
+ img.removeAttribute('src');
+ img.style.visibility = 'hidden';
+ }
+ };
+ const sp = document.createElement('span');
+ sp.className = 'ipr-name';
+ /* A Latin name truncates at the wrong end in a right-to-left locale without
+ this, and unicode-bidi:plaintext does nothing in WebKit. */
+ sp.dir = 'auto';
+ sp.textContent = name;
+ r.append(img, sp);
+ if (source) {
+ const src = document.createElement('span');
+ src.className = 'ipr-src';
+ src.textContent = t('app.iconFrom', { source: SOURCE_LABEL[source] || source });
+ r.append(src);
+ }
+ /* Enter acts on the cursor, so the pointer has to move all of it: a row that
+ is announced as active while another is marked selected sends Enter to a
+ row nothing is pointing at. */
+ r.onpointerenter = () => ipSetActive(i, { scroll: false });
+ r.onclick = () => ipChoose(i);
+ return r;
}
+
function showIPRes(list, rawInput) {
const rs = el('iprs');
if (!rs) return;
- rs.replaceChildren();
- list.forEach(ic => {
- const r = document.createElement('button');
- r.type = 'button';
- r.className = 'ipr';
- const img = document.createElement('img');
- img.alt = '';
- img.src = ic.svgUrl;
- img.onerror = () => {
- img.src = ic.pngUrl;
- };
- const sp = document.createElement('span');
- sp.textContent = ic.name;
- r.append(img, sp);
- r.onclick = () => {
- state.siurl = ic.svgUrl;
- inpById('ip-in').value = ic.svgUrl;
- updPrev();
- rs.classList.remove('open');
- };
- rs.appendChild(r);
- });
- if (!list.length && rawInput && !rawInput.includes('/')) {
- const val = cdnIconName(rawInput);
- if (!val) {
- rs.classList.remove('open');
- return;
- }
- const srcs = iconChain(val);
- if (!srcs.length) {
- rs.classList.remove('open');
- return;
- }
- const r = document.createElement('button');
- r.type = 'button';
- r.className = 'ipr';
- const img = document.createElement('img');
- img.alt = '';
- img.style.cssText = 'width:24px;height:24px;object-fit:contain;';
- let step = 0;
- img.src = srcs[0];
- img.onerror = () => {
- step++;
- if (step < srcs.length) img.src = srcs[step];
- else {
- img.onerror = null;
- img.src = '';
- img.style.display = 'none';
- }
- };
- const sp = document.createElement('span');
- sp.textContent = val;
- r.append(img, sp);
- r.onclick = () => {
- state.siurl = val;
- inpById('ip-in').value = val;
- updPrev();
- rs.classList.remove('open');
- };
- rs.appendChild(r);
+ const typed = cdnIconRef(rawInput);
+ const match = list.find(
+ ic => sameIconName(ic.name, rawInput) || sameIconName(splitIconRef(ic.ref).slug, rawInput) || ic.ref === typed,
+ );
+ if (match) {
+ /* The tile draws the matched catalogue's file, so the saved value has to be
+ that catalogue's reference. Keeping the typed text would save a name that
+ resolves to a different catalogue and loads nothing. */
+ state.siurl = match.ref;
+ updPrev(match.urls);
+ } else if (!list.length) updPrev();
+ if (!list.length) {
+ if (!typed) return ipClose();
+ /* The name may still be a file a catalogue holds without listing it. */
+ ipList = [{ ref: typed, name: typed, source: '', variants: [] }];
+ rs.replaceChildren(ipRow(0, typed, typed, ''));
+ const msg = document.createElement('p');
+ msg.className = 'ipr-msg';
+ msg.textContent = t('app.iconTryUpload');
+ rs.append(msg);
+ rs.classList.add('open');
+ inpById('ip-in')?.setAttribute('aria-expanded', 'true');
+ ipActive = -1;
+ ipSay(t('app.iconNoMatch', { q: rawInput }));
+ return;
}
- if (rs.children.length) rs.classList.add('open');
- else rs.classList.remove('open');
+ ipList = list;
+ ipActive = -1;
+ rs.replaceChildren(...list.map((ic, i) => ipRow(i, ic.ref, ic.name, ic.source, ic.urls, ic.variants)));
+ rs.classList.add('open');
+ inpById('ip-in')?.setAttribute('aria-expanded', 'true');
+ inpById('ip-in')?.removeAttribute('aria-activedescendant');
+ ipSay(t('app.iconResultCount', { count: list.length }));
}
+
function setInitialGlyph(p) {
const l = inpById('f-lbl')?.value || '?';
const s = document.createElement('span');
@@ -661,7 +827,7 @@ function setInitialGlyph(p) {
/* Several attempts are in flight at once and the half-typed ones finish last.
The token stops a stale failure replacing a finished preview. */
let prevRun = 0;
-function updPrev() {
+function updPrev(explicit) {
const p = el('ipv');
if (!p) return;
const run = ++prevRun;
@@ -670,7 +836,7 @@ function updPrev() {
setInitialGlyph(p);
return;
}
- const fallbacks = iconChain(state.siurl);
+ const fallbacks = explicit?.length ? explicit : iconChain(state.siurl);
if (!fallbacks.length) {
setInitialGlyph(p);
return;
diff --git a/public/js/admin-auth.js b/public/js/admin-auth.js
index f500868..8baf8ca 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=70ec6be1';
+import { ag, ap } from '/js/admin-shared.js?v=0f36d0dc';
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=e8b2a9f7';
+import { el, inp as inpById, qa } from '/js/utils.js?v=970a91b0';
export async function checkAuth(onLogin) {
try {
diff --git a/public/js/admin-color-control.js b/public/js/admin-color-control.js
index 63dd3d9..6606422 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=70ec6be1';
+import { PE_SVG, initInlineEdit, toast } from '/js/admin-shared.js?v=0f36d0dc';
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=e8b2a9f7';
+import { qa, q } from '/js/utils.js?v=970a91b0';
const CC_SWATCHES = ['#1c1c1e', '#8e8e93', '#f2f2f7', '#ff393c', '#ffcd00', '#35c759', '#0289ff', '#cb30df'];
export const BADGE_DEFAULT = '#1e6ef4';
diff --git a/public/js/admin-drag.js b/public/js/admin-drag.js
index e360eb3..8de314b 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=7d68e98e';
import { snapshotItems } from '/js/admin-save-logic.js?v=4f71ef6c';
-import { qa } from '/js/utils.js?v=e8b2a9f7';
+import { qa } from '/js/utils.js?v=970a91b0';
/** @type {(before: unknown) => void} */
let _save = () => {};
diff --git a/public/js/admin-list.js b/public/js/admin-list.js
index b4ec511..eff0d49 100644
--- a/public/js/admin-list.js
+++ b/public/js/admin-list.js
@@ -13,13 +13,13 @@
import { collapsedFolders, filter, state } from '/js/admin-state.js?v=7d68e98e';
import { snapshotItems } from '/js/admin-save-logic.js?v=4f71ef6c';
-import { reorderItems } from '/js/admin-logic.js?v=dcf7c37d';
-import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=f3b01482';
-import { paintIcon } from '/js/admin-shared.js?v=70ec6be1';
-import { clr as rc, el, qa, setUserText } from '/js/utils.js?v=e8b2a9f7';
+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 { 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=997ca7c6';
+import { sizeLabel } from '/js/admin-widget-form.js?v=0cb044d0';
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 c15d798..780fc25 100644
--- a/public/js/admin-logic.js
+++ b/public/js/admin-logic.js
@@ -103,6 +103,18 @@ export function collectFieldValues(reads, { includeTransient = false } = {}) {
return out;
}
+/* Loose, so "AdGuard Home", "adguard_home" and "adguard-home" are one name. */
+export function sameIconName(a, b) {
+ const norm = s =>
+ String(s ?? '')
+ .toLowerCase()
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .replace(/[^a-z0-9]/g, '');
+ const x = norm(a);
+ return !!x && x === norm(b);
+}
+
export function nextActiveIndex(key, active, len) {
if (len <= 0) return null;
const clamp = i => Math.max(0, Math.min(i, len - 1));
diff --git a/public/js/admin-settings.js b/public/js/admin-settings.js
index daf7b60..4b452c1 100644
--- a/public/js/admin-settings.js
+++ b/public/js/admin-settings.js
@@ -1,9 +1,9 @@
-import { toast, ag, ap } from '/js/admin-shared.js?v=70ec6be1';
+import { toast, ag, ap } from '/js/admin-shared.js?v=0f36d0dc';
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=dcf7c37d';
+import { shouldWritePassword, settingsSaveBlocker, clearsStoredPassword, BLOCK } from '/js/admin-logic.js?v=74cb4272';
import { confirmText } from '/js/modal.js?v=11fa1eff';
-import { el, inp, q, qa, setUserText } from '/js/utils.js?v=e8b2a9f7';
+import { el, inp, q, qa, setUserText } from '/js/utils.js?v=970a91b0';
/* Mirrors the server's rule: auth cannot be switched on with no password. */
let _passwordSet = false;
diff --git a/public/js/admin-shared.js b/public/js/admin-shared.js
index 6a16ae7..d158cb8 100644
--- a/public/js/admin-shared.js
+++ b/public/js/admin-shared.js
@@ -1,8 +1,9 @@
/* Stateless helpers shared by the admin modules. Mutable state stays out. */
-import { nextActiveIndex, recoversSession, toastHoldMs } from '/js/admin-logic.js?v=dcf7c37d';
-import { el, qa, q } from '/js/utils.js?v=e8b2a9f7';
+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 { t } from '/js/i18n.js?v=e644a5c5';
-import { iconChain } from '/js/icons.js?v=69c2b9bd';
+import { iconChain } from '/js/icons.js?v=04e7796e';
export const API = '';
@@ -218,6 +219,7 @@ export function wireChecklist(dd, btn, list, onToggle) {
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
@@ -244,6 +246,7 @@ export function wireChecklist(dd, btn, list, onToggle) {
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 => {
diff --git a/public/js/admin-widget-form.js b/public/js/admin-widget-form.js
index 5d2ecfe..49ae244 100644
--- a/public/js/admin-widget-form.js
+++ b/public/js/admin-widget-form.js
@@ -1,10 +1,10 @@
import { state } from '/js/admin-state.js?v=7d68e98e';
-import { PE_SVG, CHEV_SVG, initInlineEdit } from '/js/admin-shared.js?v=70ec6be1';
-import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=e6ec5157';
+import { PE_SVG, CHEV_SVG, initInlineEdit } from '/js/admin-shared.js?v=0f36d0dc';
+import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=1b131080';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
-import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=dcf7c37d';
+import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=74cb4272';
import { t } from '/js/i18n.js?v=e644a5c5';
-import { q, qi, qa } from '/js/utils.js?v=e8b2a9f7';
+import { q, qi, qa } from '/js/utils.js?v=970a91b0';
const SIZE_ICONS = {
small:
diff --git a/public/js/admin.js b/public/js/admin.js
index 6a5cef3..553b5a9 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=8c9ef961';
-import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=1bb8584a';
-import { initList, render, syncFilterUI } from '/js/admin-list.js?v=2067483a';
-import { resolveAdminSection } from '/js/admin-logic.js?v=dcf7c37d';
+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 {
buildAppItem,
claimFolderChildren,
@@ -10,13 +10,15 @@ import {
snapshotItems,
upsertItem,
} from '/js/admin-save-logic.js?v=4f71ef6c';
-import { loadSettings, showBgFields, showBgFit, showWallpaperFile } from '/js/admin-settings.js?v=9f7cf590';
-import { ag, ap, initInlineEdit, paintIcon, setReauthHandler, toast } from '/js/admin-shared.js?v=70ec6be1';
+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=997ca7c6';
+import { buildWidgetForm } from '/js/admin-widget-form.js?v=0cb044d0';
+import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86';
+import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=8b39e9d0';
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=69c2b9bd';
+import { loadLocalIcons } from '/js/icons.js?v=04e7796e';
import {
clearSkipTls,
convert,
@@ -29,8 +31,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=e8b2a9f7';
-import { normalizeColorInput } from '/js/admin-color-control.js?v=2d28867b';
+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 { parseYamlTolerant, YamlLiteError } from '/js/yaml-lite.js?v=6ebb564c';
import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6';
@@ -39,7 +41,10 @@ import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6'
dashboard, so the two screens cannot disagree about what mobile means. */
function _syncMobile(mobile) {
document.documentElement.classList.toggle('is-mobile', mobile);
+ syncGlideSelect();
}
+initFluidHover();
+
const _mobileAtLoad = isMobileLayout();
_syncMobile(_mobileAtLoad);
onLayoutChange(_syncMobile, _mobileAtLoad);
@@ -606,11 +611,13 @@ function initNav() {
s.hidden = s.id !== 'sec-' + id;
});
links.forEach(l => l.classList.toggle('active', l.dataset.sec === id));
+ syncGlideSelect();
localStorage.setItem(STORE, id);
}
links.forEach(l => l.addEventListener('click', () => show(l.dataset.sec)));
addEventListener('hashchange', () => show(location.hash.slice(1)));
show(location.hash.slice(1) || localStorage.getItem(STORE));
+ initGlideSelect();
}
function initAllInlineEdits() {
diff --git a/public/js/dashboard.js b/public/js/dashboard.js
index cadb38b..d255445 100644
--- a/public/js/dashboard.js
+++ b/public/js/dashboard.js
@@ -1,4 +1,4 @@
-import { loadLocalIcons, iconChain } from '/js/icons.js?v=69c2b9bd';
+import { loadLocalIcons, iconChain } from '/js/icons.js?v=04e7796e';
import {
WIDGET_HEIGHTS,
WIDGET_DESIGN,
@@ -24,13 +24,14 @@ import {
setUserText,
teardownWidgets,
titleWhenTruncated,
-} from '/js/utils.js?v=e8b2a9f7';
-import { initSpotlight } from '/js/spotlight.js?v=c81114db';
+} from '/js/utils.js?v=970a91b0';
+import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86';
+import { initSpotlight } from '/js/spotlight.js?v=2b722c16';
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=3c2c7d0b';
+import { initUI, mkFolder, openFolderDesktop, openFolderMobile, buildMobile } from '/js/ui.js?v=08ec9951';
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';
@@ -845,6 +846,7 @@ async function boot() {
};
_stateRef = state;
initUI(state);
+ initFluidHover();
initSpotlight({ getItems: () => items, isMob: () => MOB, CB, iconChain, openFolderDesktop, openFolderMobile });
/* Mobile measures the viewport as it builds, so it waits for layout. */
@@ -936,11 +938,13 @@ async function boot() {
document.body.appendChild(probe);
let _sat,
_saH = -1,
+ _saW = -1,
_saFirst = true;
new ResizeObserver(entries => {
- const h = entries[0].contentRect.height;
- if (h === _saH) return;
+ const { width: w, height: h } = entries[0].contentRect;
+ if (h === _saH && w === _saW) return;
_saH = h;
+ _saW = w;
/* Observing delivers the current size at once, and the layout being built
now already has it. */
if (_saFirst) {
diff --git a/public/js/fluid-hover.js b/public/js/fluid-hover.js
new file mode 100644
index 0000000..61233dd
--- /dev/null
+++ b/public/js/fluid-hover.js
@@ -0,0 +1,133 @@
+/* A single highlight per list, moved instead of redrawn. It follows the
+ pointer, and falls back to the keyboard cursor when the pointer leaves. */
+
+import { ensurePill, pillOf, placePill, restPill } from '/js/moving-pill.js?v=4e961dec';
+
+const FINE = '(hover: hover) and (pointer: fine)';
+
+/* Container element → { hover, kb, bound }. */
+const states = new WeakMap();
+/* Containers holding a live pill, so a resize can reposition them. Weakly
+ held: the admin rebuilds these lists on every render, and a strong set kept
+ every detached one alive until the window happened to be resized. */
+const live = new Set();
+
+function dropDetached() {
+ for (const c of live) if (!c.isConnected) live.delete(c);
+}
+
+const LISTS = [
+ { container: '#sres', item: '.sr' },
+ { container: '.row-dd-list', item: 'li[role="option"]' },
+ { container: '#iprs', item: '.ipr' },
+ { container: '.grp', item: '.row-link, .row.drow' },
+];
+
+export function fluidHoverSupported() {
+ return typeof matchMedia === 'function' && matchMedia(FINE).matches;
+}
+
+function selectable(item) {
+ if (item.hasAttribute('disabled') || item.getAttribute('aria-disabled') === 'true') return false;
+ return !item.classList.contains('d-none') && !item.classList.contains('dragging');
+}
+
+function stateFor(container) {
+ let s = states.get(container);
+ if (!s) {
+ s = { hover: null, kb: null, bound: false };
+ states.set(container, s);
+ }
+ return s;
+}
+
+function refresh(container) {
+ const s = stateFor(container);
+ /* Whichever moved last. The pointer holding it meant a keyboard cursor in a
+ hovered list had no highlight at all, because the row's own background is
+ suppressed while the pill is on, and Enter then acted on an invisible row. */
+ const target = s.hover || s.kb;
+ if (target && target.isConnected && selectable(target)) {
+ const pill = ensurePill(container, 'fh-hl');
+ live.add(container);
+ placePill(container, pill, target);
+ } else {
+ restPill(pillOf(container));
+ live.delete(container);
+ }
+ dropDetached();
+}
+
+function bind(container) {
+ const s = stateFor(container);
+ if (s.bound) return;
+ s.bound = true;
+ container.addEventListener('pointerleave', () => {
+ s.hover = null;
+ refresh(container);
+ });
+}
+
+function locate(item) {
+ if (!item || !item.matches) return null;
+ for (const cfg of LISTS) {
+ if (!item.matches(cfg.item)) continue;
+ const container = item.closest(cfg.container);
+ if (container) return container;
+ }
+ return null;
+}
+
+/* Moves the highlight to the keyboard cursor, and takes it off the pointer:
+ the last input to move is the one that owns it. */
+export function fluidHoverKb(item) {
+ if (!fluidHoverSupported()) return;
+ const container = locate(item);
+ if (!container) return;
+ const s = stateFor(container);
+ s.hover = null;
+ s.kb = item;
+ bind(container);
+ refresh(container);
+}
+
+/* For the test: proves a detached container is not retained. */
+export function _liveHolds(container) {
+ dropDetached();
+ return live.has(container);
+}
+
+export function fluidHoverClear(container) {
+ const s = states.get(container);
+ if (!s) return;
+ s.hover = null;
+ s.kb = null;
+ refresh(container);
+}
+
+export function initFluidHover(root = document) {
+ if (!fluidHoverSupported()) return;
+ root.addEventListener(
+ 'pointerover',
+ e => {
+ if (e.pointerType && e.pointerType !== 'mouse') return;
+ const from = /** @type {Element} */ (e.target);
+ if (!from || !from.closest) return;
+ for (const cfg of LISTS) {
+ const item = from.closest(cfg.item);
+ if (!item) continue;
+ const container = item.closest(cfg.container);
+ if (!container || !selectable(item)) continue;
+ stateFor(container).hover = item;
+ bind(container);
+ refresh(container);
+ return;
+ }
+ },
+ true,
+ );
+ addEventListener('resize', () => {
+ dropDetached();
+ for (const c of live) refresh(c);
+ });
+}
diff --git a/public/js/glide-select.js b/public/js/glide-select.js
new file mode 100644
index 0000000..89b18ae
--- /dev/null
+++ b/public/js/glide-select.js
@@ -0,0 +1,57 @@
+/* The selected-item pill in the settings navigation. It travels from the old
+ item to the new one instead of fading in place.
+
+ Not gated on a pointer: this is selection, not hover, so the phone tab bar
+ gets it too. */
+
+import { ensurePill, measurable, placePill } from '/js/moving-pill.js?v=4e961dec';
+
+const NAVS = [
+ { container: '.sb-nav', item: '.nl', holdInk: true },
+ { container: '.mtabbar', item: '.mtab', holdInk: false },
+];
+
+/* Container → the item its pill is on, so a move is told from a reposition. */
+const at = new WeakMap();
+/* Container → the pending timer that lifts the ink hold. */
+const timers = new WeakMap();
+const GLIDE_MS = 320;
+
+/** Places each nav's pill on its active item. Both navs are in the document at
+ once and the one belonging to the other layout is hidden, so the hidden one
+ is left alone rather than placed against a zero rect. */
+export function syncGlideSelect(root = document) {
+ for (const cfg of NAVS) {
+ for (const container of root.querySelectorAll(cfg.container)) {
+ if (!measurable(container)) continue;
+ const active = container.querySelector(cfg.item + '.active');
+ if (!active) continue;
+ const was = at.get(container);
+ /* The label ink is held only for a real move. On the first placement
+ there is nothing to travel from, so the pill and the ink land together. */
+ if (cfg.holdInk && was && was !== active) {
+ container.classList.add('gs-moving');
+ clearTimeout(timers.get(container));
+ timers.set(
+ container,
+ setTimeout(() => container.classList.remove('gs-moving'), GLIDE_MS),
+ );
+ }
+ if (placePill(container, ensurePill(container, 'gs-pill'), active)) at.set(container, active);
+ }
+ }
+}
+
+/* Watch the navs rather than the window. A nav is laid out later than this
+ runs: the tab bar stays hidden until the page is marked authenticated, and
+ the two navs swap when the layout crosses the mobile breakpoint. Observing
+ the container catches every one of those without a caller having to know. */
+export function initGlideSelect(root = document) {
+ if (typeof ResizeObserver === 'function') {
+ const ro = new ResizeObserver(() => syncGlideSelect(root));
+ for (const cfg of NAVS) for (const nav of root.querySelectorAll(cfg.container)) ro.observe(nav);
+ } else {
+ addEventListener('resize', () => syncGlideSelect(root));
+ }
+ syncGlideSelect(root);
+}
diff --git a/public/js/icons.js b/public/js/icons.js
index 3b09eb0..052dec2 100644
--- a/public/js/icons.js
+++ b/public/js/icons.js
@@ -31,6 +31,29 @@ export function resolveIcon(raw) {
return LOCAL_ICONS.has(filename) ? iconPath(filename) : '';
}
+/* The catalogues a name can come from, in the order the picker ranks them. A
+ bare name is dashboard-icons: every icon saved before the other three
+ existed is written that way. */
+const SOURCES = ['di', 'selfhst', 'simple', 'lobe'];
+
+const CDN_BASE = {
+ di: 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons',
+ selfhst: 'https://cdn.jsdelivr.net/gh/selfhst/icons',
+ simple: 'https://cdn.jsdelivr.net/gh/simple-icons/simple-icons/icons',
+ lobe: 'https://cdn.jsdelivr.net/gh/lobehub/lobe-icons/packages/static-svg/icons',
+};
+
+/** Split a stored reference into its catalogue and slug. */
+export function splitIconRef(raw) {
+ const s = String(raw || '').trim();
+ const i = s.indexOf(':');
+ if (i > 0) {
+ const source = s.slice(0, i);
+ if (SOURCES.includes(source)) return { source, slug: s.slice(i + 1) };
+ }
+ return { source: 'di', slug: s };
+}
+
/* CDN names only. The catalogue is lowercase, hyphenated and case-sensitive; a
local filesystem may hold two names differing by case. */
export function cdnIconName(name) {
@@ -42,6 +65,19 @@ export function cdnIconName(name) {
.replace(/^-|-$/g, '');
}
+/** Keep the catalogue prefix, normalise only the slug after it. */
+export function cdnIconRef(raw) {
+ const { source, slug } = splitIconRef(raw);
+ const name = cdnIconName(slug);
+ if (!name) return '';
+ return source === 'di' ? name : `${source}:${name}`;
+}
+
+function cdnFileUrl(source, slug, ext) {
+ if (source === 'simple' || source === 'lobe') return ext === 'svg' ? `${CDN_BASE[source]}/${slug}.svg` : '';
+ return `${CDN_BASE[source]}/${ext}/${slug}.${ext}`;
+}
+
export function iconChain(rawIcon) {
if (!rawIcon) return [];
const localUrl = resolveIcon(rawIcon);
@@ -58,16 +94,22 @@ export function iconChain(rawIcon) {
if (localUrl && rawIcon !== localUrl) chain.push(rawIcon);
if (!localUrl) chain.push(rawIcon);
} else {
- const cdn = encodeURIComponent(cdnIconName(name));
+ const { source, slug } = splitIconRef(name);
+ const cdn = encodeURIComponent(cdnIconName(slug));
+ const src = `&source=${source}`;
/* Through the API first, which holds the file, so the CDN is not told which
services this dashboard shows on every load. The direct link stays as the
fallback for a failed proxy fetch. */
- if (!explicitExt || explicitExt === 'svg') chain.push(`/api/icons/cdn?name=${cdn}&ext=svg`);
- if (!explicitExt || explicitExt === 'png') chain.push(`/api/icons/cdn?name=${cdn}&ext=png`);
- if (!explicitExt || explicitExt === 'svg')
- chain.push(`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/${cdn}.svg`);
- if (!explicitExt || explicitExt === 'png')
- chain.push(`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/${cdn}.png`);
+ /* 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}`);
+ for (const ext of ['svg', 'png']) {
+ if (explicitExt && explicitExt !== ext) continue;
+ const direct = cdnFileUrl(source, cdn, ext);
+ if (direct) chain.push(direct);
+ }
}
return chain;
}
diff --git a/public/js/moving-pill.js b/public/js/moving-pill.js
new file mode 100644
index 0000000..f53b85c
--- /dev/null
+++ b/public/js/moving-pill.js
@@ -0,0 +1,68 @@
+/* One element moved between the rows of a list, instead of a fill redrawn on
+ each row. Shared by the hover highlight and the navigation selection.
+
+ The pill is prepended, so it paints before its siblings and they cover it.
+ Give it no z-index: one on a row makes that row a stacking context, and an
+ overlay inside the row is then trapped behind every row that follows. */
+
+/** @param {Element} container @returns {HTMLElement|null} */
+export function pillOf(container) {
+ const first = container.firstElementChild;
+ return first && first.classList.contains('mp') ? /** @type {HTMLElement} */ (first) : null;
+}
+
+/* Created on demand, never restored after a re-render: a child that is always
+ there makes `:empty` false, which keeps an empty list on screen. */
+export function ensurePill(container, variant) {
+ let p = pillOf(container);
+ if (!p) {
+ p = document.createElement('div');
+ p.classList.add('mp', variant);
+ p.setAttribute('aria-hidden', 'true');
+ container.prepend(p);
+ }
+ return p;
+}
+
+/** Measurable means laid out: a nav hidden for the other layout reports zeroes,
+ and placing against those puts the pill in the corner.
+ @param {Element} el */
+export function measurable(el) {
+ const r = el.getBoundingClientRect();
+ return r.width > 0 && r.height > 0;
+}
+
+/** @param {Element} container @param {HTMLElement} pill @param {Element} item */
+export function placePill(container, pill, item) {
+ if (!measurable(container) || !measurable(item)) return false;
+ const cr = container.getBoundingClientRect();
+ const ir = item.getBoundingClientRect();
+ const cs = getComputedStyle(item);
+ pill.style.borderRadius = [
+ cs.borderTopLeftRadius,
+ cs.borderTopRightRadius,
+ cs.borderBottomRightRadius,
+ cs.borderBottomLeftRadius,
+ ].join(' ');
+ pill.style.width = ir.width + 'px';
+ pill.style.height = ir.height + 'px';
+ /* The pill is anchored at the inline start, so right-to-left measures from
+ the right edge and travels the other way. None of these lists scroll
+ sideways, so the rect difference is the whole inline offset. */
+ const rtl = cs.direction === 'rtl';
+ const inline = (rtl ? cr.right - ir.right : ir.left - cr.left) - container.clientLeft;
+ const block = ir.top - cr.top - container.clientTop + container.scrollTop;
+ pill.style.transform = 'translate3d(' + (rtl ? -inline : inline) + 'px,' + block + 'px,0)';
+ if (!pill.classList.contains('on')) {
+ /* `.on` carries the transform transition, so it has to land in a later
+ style pass or the pill slides in from the corner. */
+ pill.getBoundingClientRect();
+ pill.classList.add('on');
+ }
+ return true;
+}
+
+/** @param {HTMLElement|null} pill */
+export function restPill(pill) {
+ if (pill) pill.classList.remove('on');
+}
diff --git a/public/js/spotlight.js b/public/js/spotlight.js
index 6f1ce91..8121b42 100644
--- a/public/js/spotlight.js
+++ b/public/js/spotlight.js
@@ -1,4 +1,5 @@
-import { mk, clr, el, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=e8b2a9f7';
+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 { t } from '/js/i18n.js?v=e644a5c5';
/* Attached to the window so a re-open can undo the previous one. */
@@ -130,6 +131,7 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto
f.appendChild(a);
});
res.appendChild(f);
+ fluidHoverClear(res);
if (live) live.textContent = cur.length + ' ' + (cur.length === 1 ? t('home.result') : t('home.results'));
inp.setAttribute('aria-expanded', 'true');
inp.setAttribute('aria-activedescendant', cur.length ? 'sr-opt-0' : '');
@@ -143,6 +145,7 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto
if (on) {
e.scrollIntoView({ block: 'nearest' });
inp.setAttribute('aria-activedescendant', e.id);
+ fluidHoverKb(e);
}
});
diff --git a/public/js/ui.js b/public/js/ui.js
index bb002b5..a1464e5 100644
--- a/public/js/ui.js
+++ b/public/js/ui.js
@@ -1,4 +1,4 @@
-import { iconChain } from '/js/icons.js?v=69c2b9bd';
+import { iconChain } from '/js/icons.js?v=04e7796e';
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=e8b2a9f7';
+} from '/js/utils.js?v=970a91b0';
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';
@@ -581,7 +581,12 @@ export function buildMobile() {
/* Every size below comes from this box, so the safe-area insets reach the
layout as the space the stylesheet already reserved. Reading the insets
- instead would take a value the platform has not reported yet. */
+ instead would take a value the platform has not reported yet.
+
+ Clear the width first. The grid is sized by --mgw, so a rebuild would
+ otherwise measure the width it was given last time rather than the space
+ it now has, and a reserve that grew would never be taken. */
+ css(document.body, { '--mgw': '' });
const firstPage = mkPage();
strip.appendChild(firstPage.page);
const gridBox = firstPage.grid.getBoundingClientRect();
diff --git a/public/js/utils.js b/public/js/utils.js
index f18a8cf..f75d3c0 100644
--- a/public/js/utils.js
+++ b/public/js/utils.js
@@ -1,4 +1,4 @@
-import { iconChain } from '/js/icons.js?v=69c2b9bd';
+import { iconChain } from '/js/icons.js?v=04e7796e';
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 b9b2961..e36b481 100644
--- a/public/js/widget-config-form.js
+++ b/public/js/widget-config-form.js
@@ -4,8 +4,8 @@
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=70ec6be1';
-import { renderColorControl } from '/js/admin-color-control.js?v=2d28867b';
+import { wireChecklist } from '/js/admin-shared.js?v=0f36d0dc';
+import { renderColorControl } from '/js/admin-color-control.js?v=d162431d';
import {
seedCarried,
applyOptionSet,
@@ -13,9 +13,9 @@ import {
requiredFieldMissing,
groupBounds,
visibleFieldFlags,
-} from '/js/admin-logic.js?v=dcf7c37d';
+} from '/js/admin-logic.js?v=74cb4272';
import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1';
-import { qi } from '/js/utils.js?v=e8b2a9f7';
+import { qi } from '/js/utils.js?v=970a91b0';
const PE =
'