diff --git a/CHANGELOG.md b/CHANGELOG.md index 52a7d8f9..c3e00823 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The password and Unsplash key status labels are translated instead of always reading in English. +- Escape and the Tab trap keep working in a folder or prompt overlay after + clicking text inside it. + ### Security - A stored credential is only reused for the request it was saved for. diff --git a/ui/admin/index.html b/ui/admin/index.html index ef5fcf49..35386184 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -285,6 +285,6 @@

General

- + diff --git a/ui/index.html b/ui/index.html index 7f01fb9b..a232c22b 100644 --- a/ui/index.html +++ b/ui/index.html @@ -54,5 +54,5 @@

Stackyard

- + diff --git a/ui/js/admin.js b/ui/js/admin.js index 0b01502e..0d075a29 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -13,9 +13,9 @@ import { loadSettings, showBgFields } from '/js/admin-settings.js?v=f189373f'; import { canJoinFolder, applyDrop } from '/js/admin-drag-logic.js?v=53aeaa55'; /* openModal is the item editor in this file, so the shared dialog comes in under a different name. */ -import { openModal as openDialog, confirmModal, promptModal } from '/js/modal.js?v=10d34989'; -import { parseYaml, YamlLiteError } from '/js/yaml-lite.js?v=a3f8f22e'; -import { detectSource, convert, SKIP, NOTE } from '/js/import-foreign.js?v=e0db2fb3'; +import { openModal as openDialog, confirmModal, promptModal } from '/js/modal.js?v=684b74e0'; +import { parseYaml, YamlLiteError } from '/js/yaml-lite.js?v=14aa75ec'; +import { detectSource, convert, SKIP, NOTE } from '/js/import-foreign.js?v=cf38ec0b'; /* Admin UI: Stackyard Dashboard */ diff --git a/ui/js/dashboard.js b/ui/js/dashboard.js index a1b31bc2..df1819d8 100644 --- a/ui/js/dashboard.js +++ b/ui/js/dashboard.js @@ -22,15 +22,15 @@ import { qa, setUserText, } from '/js/utils.js?v=84d58686'; -import { initSpotlight } from '/js/spotlight.js?v=a5ba7f75'; +import { initSpotlight } from '/js/spotlight.js?v=7aa670dc'; import { html, setHtml, raw } from '/js/html.js?v=ccec347c'; import { initI18n, t, currentLang } from '/js/i18n.js?v=133a7aac'; import { pwStrength, passwordMismatch } from '/js/password-strength.js?v=dab9978e'; import { sanitizeItemLinks } from '/js/link-url.js?v=19038560'; -import { initUI, mkFolder, openFolderDesktop, openFolderMobile, buildMobile } from '/js/ui.js?v=3d60a9bc'; +import { initUI, mkFolder, openFolderDesktop, openFolderMobile, buildMobile } from '/js/ui.js?v=1515288b'; import { badgeSignature, computeBadgeVisual } from '/js/badge-logic.js?v=d278c683'; import { configChanged, landingAfterSetup } from '/js/dashboard-logic.js?v=640430ba'; -import { trapFocus } from '/js/dialog.js?v=4ff94595'; +import { trapFocus } from '/js/dialog.js?v=b3841546'; const MOB = innerWidth <= 768 || /iPhone|iPod|Android/i.test(navigator.userAgent); diff --git a/ui/js/dialog.js b/ui/js/dialog.js index e30f8c16..e2e37bbd 100644 --- a/ui/js/dialog.js +++ b/ui/js/dialog.js @@ -89,7 +89,7 @@ export function trapFocus(root, opts = {}) { function release() { if (released) return; released = true; - root.removeEventListener('keydown', /** @type {EventListener} */ (onKeydown)); + document.removeEventListener('keydown', /** @type {EventListener} */ (onKeydown), true); /* Without this, closing leaves focus on nothing and the next Tab starts from the top of the page. Skipped if the element has left the document. */ if (restoreTo && restoreTo.focus && restoreTo.isConnected) { @@ -101,7 +101,12 @@ export function trapFocus(root, opts = {}) { } } - root.addEventListener('keydown', /** @type {EventListener} */ (onKeydown)); + /* On the document rather than on root, because a dialog holds text that is not + focusable: clicking a line of it moves focus to the body, and a listener + inside root would then never see the key. Escape and the Tab wrap have to + work from wherever the click left focus, which is the case wrapTab's + out-of-root branch already covers. */ + document.addEventListener('keydown', /** @type {EventListener} */ (onKeydown), true); const target = initialFocus || focusableWithin(root)[0]; if (target && target.focus) { diff --git a/ui/js/import-foreign.js b/ui/js/import-foreign.js index b4b371c0..2584c0b1 100644 --- a/ui/js/import-foreign.js +++ b/ui/js/import-foreign.js @@ -131,7 +131,7 @@ export function convertIcon(raw) { } return host === 'cdn.jsdelivr.net' ? { iconUrl: s, dropped: false } : { iconUrl: '', dropped: true }; } - if (s.startsWith('/') || s.includes('/')) return { iconUrl: '', dropped: true }; + if (s.includes('/')) return { iconUrl: '', dropped: true }; /* selfh.st and homelab-svg-assets prefixes. Both name the same service the dashboard-icons catalogue does, so the remainder is a usable slug. */ const body = /^(sh|hl)-/i.test(s) ? s.slice(3) : s; @@ -148,9 +148,11 @@ export function convertIcon(raw) { /** Collector for one conversion run. Keeps ids unique across every file in the batch, including against the items already on the dashboard. + A Set given by the caller is used as it is rather than copied: the ids of one + file have to be visible while the next one in the batch is converted. @param {Iterable} takenIds */ function collector(takenIds) { - const taken = new Set(takenIds || []); + const taken = takenIds instanceof Set ? takenIds : new Set(takenIds || []); return { taken, /** @type {any[]} */ items: [], @@ -242,7 +244,7 @@ export function convertHomepageServices(doc, takenIds = []) { const col = collector(takenIds); if (!Array.isArray(doc)) return result(col); - const walk = (entries, groupLabel, depth) => { + const walk = (entries, groupLabel) => { const at = col.items.length; const children = []; for (const entry of Array.isArray(entries) ? entries : []) { @@ -262,7 +264,7 @@ export function convertHomepageServices(doc, takenIds = []) { /* The child's own name, not the compound one: the preview prints "group / name", so passing the compound name repeats the parent. */ col.note(NOTE.GROUP_FLATTENED, name, groupLabel, nested); - walk(value, nested, depth + 1); + walk(value, nested); continue; } if (!isMap(value)) { @@ -295,8 +297,14 @@ export function convertHomepageServices(doc, takenIds = []) { for (const group of doc) { const g = soleEntry(group); - if (!g || !Array.isArray(g[1])) continue; - walk(g[1], g[0], 0); + if (!g || !Array.isArray(g[1])) { + /* A top-level group the file shapes differently from the rest. detectSource + settles the format on the first entry that matches, so one of these does + not stop the import, and passing over it loses the whole group. */ + col.skip(SKIP.UNREADABLE, g ? g[0] : '', ''); + continue; + } + walk(g[1], g[0]); } return result(col); } @@ -308,7 +316,10 @@ export function convertHomepageBookmarks(doc, takenIds = []) { for (const group of doc) { const g = soleEntry(group); - if (!g || !Array.isArray(g[1])) continue; + if (!g || !Array.isArray(g[1])) { + col.skip(SKIP.UNREADABLE, g ? g[0] : '', ''); + continue; + } const [groupLabel, entries] = g; const at = col.items.length; const children = []; @@ -371,8 +382,6 @@ export function convertDashy(doc, takenIds = [], untitledFolder = 'Imported') { } const title = text(raw.title); const icon = convertIcon(raw.icon); - /* localUrl is the address on the other network. Importing it would give - half the tiles a link that only works from somewhere else. */ const href = str(raw.url); const check = raw.statusCheck === true; /* Dashy's per-item switch for a health check against a self-signed @@ -390,15 +399,22 @@ export function convertDashy(doc, takenIds = [], untitledFolder = 'Imported') { children.push(app.id); if (icon.dropped) col.note(NOTE.ICON_DROPPED, title, groupLabel, str(raw.icon)); if (viaSubItem) col.note(NOTE.SUBITEMS_FLATTENED, title, groupLabel); - /* A second address for the same service, reachable only from the other - network. Reported rather than dropped in silence, because the tile - keeps whichever of the two the source called the primary one. */ + /* localUrl is a second address for the same service, reachable only from + the other network, so importing it would give half the tiles a link that + works from somewhere else. Reported rather than dropped in silence, + because the tile keeps whichever of the two the source called primary. */ if (str(raw.localUrl)) col.note(NOTE.LOCAL_URL_DROPPED, title, groupLabel, str(raw.localUrl)); noteIgnored(col, raw, title, groupLabel); for (const sub of Array.isArray(raw.subItems) ? raw.subItems : []) addDashyItem(sub, true); }; - for (const item of Array.isArray(section.items) ? section.items : []) addDashyItem(item, false); + if (Array.isArray(section.items)) for (const item of section.items) addDashyItem(item, false); + else if (!Array.isArray(section.widgets)) { + /* A section shaped like one but carrying nothing readable. Without this it + contributes no items, no skips and no notes, and the preview shows a + file smaller than the one that was chosen with nothing saying why. */ + col.skip(SKIP.UNREADABLE, groupLabel, ''); + } /* A section widget has no link of its own to keep, unlike a Homepage service widget, so there is nothing to import in its place. */ if (Array.isArray(section.widgets) && section.widgets.length) diff --git a/ui/js/modal.js b/ui/js/modal.js index a8d13f83..540d806a 100644 --- a/ui/js/modal.js +++ b/ui/js/modal.js @@ -9,7 +9,7 @@ Structure only. Callers fill the body with their own nodes. */ -import { trapFocus } from '/js/dialog.js?v=4ff94595'; +import { trapFocus } from '/js/dialog.js?v=b3841546'; /** Open a modal and return its parts. @@ -57,8 +57,15 @@ export function openModal({ title, className, onClose }) { if (onClose) onClose(); }; + /* Both ends of the click, or a text selection that starts inside the dialog + and is released past its edge dismisses it, discarding whatever the person + was reading closely enough to select. */ + let downOnBackdrop = false; + ov.onmousedown = e => { + downOnBackdrop = e.target === ov; + }; ov.onclick = e => { - if (e.target === ov) close(); + if (e.target === ov && downOnBackdrop) close(); }; const addAction = (label, cls, onAct) => { @@ -79,6 +86,9 @@ export function openModal({ title, className, onClose }) { document.body.appendChild(ov); const focus = initial => { + /* Arming twice is a caller re-rendering the body. The previous trap has to + go, or its listener outlives the dialog and its restore target is lost. */ + release(); release = trapFocus(box, { onClose: close, initialFocus: initial }); }; diff --git a/ui/js/spotlight.js b/ui/js/spotlight.js index 0dbb8cdc..5335a4a0 100644 --- a/ui/js/spotlight.js +++ b/ui/js/spotlight.js @@ -1,5 +1,5 @@ import { mk, clr, el, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=84d58686'; -import { wrapTab } from '/js/dialog.js?v=4ff94595'; +import { wrapTab } from '/js/dialog.js?v=b3841546'; import { t } from '/js/i18n.js?v=133a7aac'; /* Attached to the window so a re-open can undo the previous one. Not a diff --git a/ui/js/ui.js b/ui/js/ui.js index ee2d68bd..dc9dacea 100644 --- a/ui/js/ui.js +++ b/ui/js/ui.js @@ -12,7 +12,7 @@ import { setUserText, } from '/js/utils.js?v=84d58686'; import { t, currentLang } from '/js/i18n.js?v=133a7aac'; -import { trapFocus } from '/js/dialog.js?v=4ff94595'; +import { trapFocus } from '/js/dialog.js?v=b3841546'; import { mobileMetrics } from '/js/mobile-metrics.js?v=b32c23b3'; let _state = null; diff --git a/ui/js/yaml-lite.js b/ui/js/yaml-lite.js index f2891956..7ea6ebcb 100644 --- a/ui/js/yaml-lite.js +++ b/ui/js/yaml-lite.js @@ -34,6 +34,11 @@ export class YamlLiteError extends Error { "url: http://host:8080" splits once and keeps the port. */ const KEY_RE = /^(?:(?:"((?:[^"\\]|\\.)*)")|(?:'((?:[^']|'')*)')|([^:#]+?))\s*:(?:\s+(.*))?$/; +/** The escapes a double-quoted form decodes. Shared so a key and a value spell + the same text the same way. + @param {string} s */ +const unescapeDouble = s => s.replace(/\\(["\\/nrt])/g, (_, c) => ({ n: '\n', r: '\r', t: '\t' })[c] || c); + /** @param {string} raw @param {number} line */ function scalar(raw, line) { const s = raw.trim(); @@ -41,7 +46,7 @@ function scalar(raw, line) { if (s[0] === '"') { const m = /^"((?:[^"\\]|\\.)*)"\s*$/.exec(s); if (!m) throw new YamlLiteError('unterminated double-quoted value', line); - return m[1].replace(/\\(["\\/nrt])/g, (_, c) => ({ n: '\n', r: '\r', t: '\t' })[c] || c); + return unescapeDouble(m[1]); } if (s[0] === "'") { const m = /^'((?:[^']|'')*)'\s*$/.exec(s); @@ -81,6 +86,7 @@ const BLOCK_RE = /^(.*?):\s*([|>])([-+]?)(\d*)\s*$/; @param {string[]} raw the block's lines, already stripped of its indentation @param {string} style either "|" or ">" @param {string} chomp */ function foldBlock(raw, style, chomp) { + if (!raw.length) return ''; let body; if (style === '|') body = raw.join('\n'); else { @@ -93,6 +99,8 @@ function foldBlock(raw, style, chomp) { } } if (chomp === '-') return body.replace(/\n+$/, ''); + /* "keep" means every trailing newline the block ended with, so the blank lines + have to still be here rather than trimmed off before the fold. */ if (chomp === '+') return body + '\n'; return body.replace(/\n+$/, '') + (body.length ? '\n' : ''); } @@ -106,13 +114,16 @@ function scan(text) { becomes part of the first key and the document reads as malformed. */ if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); const rows = text.split(/\r\n|\r|\n/); + /* The empty string after a final newline is not a line of the file. It only + shows where a block scalar counts the blank lines it ends with. */ + if (rows.length > 1 && rows[rows.length - 1] === '') rows.pop(); for (let i = 0; i < rows.length; i++) { const raw = rows[i]; const line = i + 1; if (/^\s*$/.test(raw)) continue; if (/^\s*#/.test(raw)) continue; - if (/^\t/.test(raw) || /^ *\t/.test(raw)) throw new YamlLiteError('tab indentation is not supported', line); + if (/^ *\t/.test(raw)) throw new YamlLiteError('tab indentation is not supported', line); const trimmed = raw.trim(); if (trimmed === '---') { /* One leading marker is ordinary. A second means a multi-document file, @@ -121,10 +132,11 @@ function scan(text) { continue; } if (trimmed === '...') throw new YamlLiteError('multi-document files are not supported', line); - /* Before the anchor check: a merge key is written with an alias, and naming - the alias would send someone looking for the wrong thing. */ + /* Named before the general anchor refusal, which a merge key would also + trip, because naming the alias sends someone looking for the wrong thing. + Anchors themselves are refused where a node begins rather than here: a + value is free text, and `description: The *arr stack` is not an alias. */ if (/^<<\s*:/.test(trimmed)) throw new YamlLiteError('merge keys are not supported', line); - if (/(^|\s)[&*][^\s]/.test(trimmed)) throw new YamlLiteError('anchors and aliases are not supported', line); const indent = raw.length - raw.trimStart().length; const bm = BLOCK_RE.exec(trimmed); @@ -148,7 +160,6 @@ function scan(text) { if (ri < base) break; body.push(r.slice(base)); } - while (body.length && body[body.length - 1] === '') body.pop(); out.push({ indent, text: bm[1].trim() + ':', line, block: foldBlock(body, bm[2], bm[3]) }); i = j - 1; continue; @@ -165,6 +176,13 @@ function block(lines, cur, indent) { return first.text.startsWith('- ') || first.text === '-' ? sequence(lines, cur, indent) : mapping(lines, cur, indent); } +/** Refuse an anchor or alias where a node begins. Only the start of a node is + checked, so the same characters inside a value stay ordinary text. + @param {string} text @param {number} line */ +function refuseAnchor(text, line) { + if (/^[&*]\S/.test(text)) throw new YamlLiteError('anchors and aliases are not supported', line); +} + /** @param {Line[]} lines @param {{ pos: number }} cur @param {number} indent */ function sequence(lines, cur, indent) { const out = []; @@ -173,7 +191,8 @@ function sequence(lines, cur, indent) { if (l.indent < indent) break; if (l.indent > indent) throw new YamlLiteError('unexpected indentation', l.line); if (!(l.text.startsWith('- ') || l.text === '-')) break; - const rest = l.text === '-' ? '' : l.text.slice(2).trim(); + const after = l.text.slice(1); + const rest = after.trim(); cur.pos++; if (rest === '') { /* "-" alone: the element is the block indented under it. */ @@ -181,12 +200,23 @@ function sequence(lines, cur, indent) { else out.push(null); continue; } + refuseAnchor(rest, l.line); + if (rest.startsWith('- ') || rest === '-') + throw new YamlLiteError('a sequence inside a sequence line is not supported', l.line); + /* Ahead of the key match, or a flow mapping's own brace and first key read + as a key line and the element parses into something that was never in the + file. scalar refuses it, and allows the empty pair. */ + if (rest[0] === '{' || rest[0] === '[') { + out.push(scalar(rest, l.line)); + continue; + } const m = KEY_RE.exec(rest); if (m) { /* The compound "- key: value" form. The element is a mapping whose first - key sits on the dash line, so its own indentation is the column the - key text starts at, not the dash. */ - const inner = indent + 2; + key sits on the dash line, so its own indentation is the column the key + text starts at, not a fixed offset from the dash: "- key" is as valid + as "- key" and its continuation lines line up with the key. */ + const inner = indent + 1 + (after.length - after.trimStart().length); const map = Object.create(null); assign(map, m, l.line, lines, cur, inner, l); while (cur.pos < lines.length && lines[cur.pos].indent === inner) { @@ -211,7 +241,7 @@ function sequence(lines, cur, indent) { @param {Line[]} lines @param {{ pos: number }} cur @param {number} indent @param {Line} [own] the key's own line, when it carried a block scalar */ function assign(map, m, line, lines, cur, indent, own) { - const key = m[1] !== undefined ? m[1] : m[2] !== undefined ? m[2].replace(/''/g, "'") : m[3].trim(); + const key = m[1] !== undefined ? unescapeDouble(m[1]) : m[2] !== undefined ? m[2].replace(/''/g, "'") : m[3].trim(); if (own && own.block !== undefined) { map[key] = own.block; return; @@ -253,6 +283,7 @@ function mapping(lines, cur, indent) { Only valid as the whole value of the key above, which assign handles. */ break; } + refuseAnchor(l.text, l.line); const m = KEY_RE.exec(l.text); if (!m) throw new YamlLiteError('expected "key: value"', l.line); cur.pos++; diff --git a/ui/test/dialog.test.mjs b/ui/test/dialog.test.mjs index b1e4cd9d..4e7557e1 100644 --- a/ui/test/dialog.test.mjs +++ b/ui/test/dialog.test.mjs @@ -111,16 +111,37 @@ function makeDom() { return node; }; - globalThis.document = { + /* The trap listens on the document, not on the dialog, because a click on + text inside the dialog leaves focus on the body and a listener under the + root would never see the key. So the document needs the same machinery a + node has. */ + const doc = { get activeElement() { return active; }, createElement: t => el(t), + addEventListener(type, fn) { + if (!listeners.has(this)) listeners.set(this, []); + listeners.get(this).push({ type, fn }); + }, + removeEventListener(type, fn) { + const l = listeners.get(this) || []; + const at = l.findIndex(x => x.type === type && x.fn === fn); + if (at !== -1) l.splice(at, 1); + }, + _fire(type, event) { + for (const l of (listeners.get(this) || []).filter(x => x.type === type)) l.fn(event); + }, + _listenerCount() { + return (listeners.get(this) || []).length; + }, }; + globalThis.document = doc; globalThis.getComputedStyle = () => ({ visibility: 'visible', display: 'block', position: 'static' }); return { el, + doc, setActive: n => { active = n; }, @@ -308,7 +329,7 @@ test('Escape closes by default', async () => { closed++; }, }); - ov._fire('keydown', keyEvent('Escape')); + dom.doc._fire('keydown', keyEvent('Escape')); assert.equal(closed, 1); }); }); @@ -325,7 +346,7 @@ test('Escape can be turned off', async () => { closed++; }, }); - ov._fire('keydown', keyEvent('Escape')); + dom.doc._fire('keydown', keyEvent('Escape')); assert.equal(closed, 0); }); }); @@ -333,11 +354,11 @@ test('Escape can be turned off', async () => { test('releasing removes the listener', async () => { await withDom(({ trapFocus }, dom) => { const { ov } = dialogOf(dom); - const before = ov._listenerCount(); + const before = dom.doc._listenerCount(); const release = trapFocus(ov); - assert.equal(ov._listenerCount(), before + 1); + assert.equal(dom.doc._listenerCount(), before + 1); release(); - assert.equal(ov._listenerCount(), before, 'a trap that outlives its dialog would capture later keys'); + assert.equal(dom.doc._listenerCount(), before, 'a trap that outlives its dialog would capture later keys'); }); }); diff --git a/ui/test/import-foreign.test.mjs b/ui/test/import-foreign.test.mjs index e9899d4f..6c6dbc15 100644 --- a/ui/test/import-foreign.test.mjs +++ b/ui/test/import-foreign.test.mjs @@ -360,3 +360,44 @@ test('conversion adds items only, so nothing existing is touched', () => { assert.equal(item.type === 'folder' || item.type === 'app', true); } }); + +/* detectSource settles the format on the first entry that matches, so a group + the rest of the file does not agree with reaches the converter. Passing over + it imported a smaller dashboard with nothing in the preview saying so. */ +test('a group that is not a list is counted, not passed over', () => { + const doc = parseYaml('- Dev:\n - Git:\n href: http://git\n- Broken: notalist\n'); + const out = convertHomepageServices(doc, []); + assert.equal(apps(out).length, 1); + assert.equal(byName(out.skipped, 'Broken').length, 1); + assert.equal(out.skipped[0].reason, SKIP.UNREADABLE); +}); + +test('a bookmarks group that is not a list is counted too', () => { + const doc = [{ Dev: [{ Git: [{ href: 'http://git' }] }] }, { Broken: 'notalist' }]; + const out = convertHomepageBookmarks(doc, []); + assert.equal(apps(out).length, 1); + assert.equal(byName(out.skipped, 'Broken').length, 1); +}); + +test('a Dashy section with no readable items is counted, not dropped', () => { + const out = convertDashy({ sections: [{ name: 'Apps' }] }, []); + assert.equal(out.items.length, 0); + assert.equal(byName(out.skipped, 'Apps').length, 1); + assert.equal(out.skipped[0].reason, SKIP.UNREADABLE); +}); + +/* A section that only holds widgets already reports them as dropped, so a + second line about the same section would say nothing new. */ +test('a Dashy section holding only widgets reports the widgets alone', () => { + const out = convertDashy({ sections: [{ name: 'Stats', widgets: [{ type: 'cpu' }] }] }, []); + assert.deepEqual(out.skipped, []); + assert.equal(out.notes[0].code, NOTE.WIDGETS_DROPPED); +}); + +/* The caller builds one set and passes it to every file in the batch. Copying + it inside meant the second file could not see what the first allocated. */ +test('the caller sees the ids a conversion allocated', () => { + const taken = new Set(['Plex_abc']); + const out = convertHomepageServices(parseYaml(SERVICES), taken); + for (const item of out.items) assert.ok(taken.has(item.id), 'the id is in the caller’s set'); +}); diff --git a/ui/test/modal.test.mjs b/ui/test/modal.test.mjs new file mode 100644 index 00000000..c9a1bb57 --- /dev/null +++ b/ui/test/modal.test.mjs @@ -0,0 +1,243 @@ +/* The dialog the admin UI opens for anything the browser's own confirm() and + prompt() cannot show. It replaced them, so it owes what they gave for free: + a backdrop that dismisses, Escape, and an answer that comes back once. + + The dismissal routes are what these cover. A dialog that closes when it + should not is worse than the browser's version, because the import preview + behind it is a list someone reads before deciding, and a dropped answer + reads as Cancel and discards the whole import. */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { register } from 'node:module'; + +register('./js-root-hooks.mjs', import.meta.url); + +/* Enough DOM for appending, dismissing and focus. Layout decides focusability + in a browser and there is none here, so everything visible counts. */ +function makeDom() { + const listeners = new Map(); + let active = null; + + const el = tag => ({ + tagName: tag.toUpperCase(), + children: [], + parent: null, + isConnected: true, + disabled: false, + className: '', + textContent: '', + offsetParent: {}, + attrs: new Map(), + focus() { + active = this; + }, + getAttribute(k) { + return this.attrs.has(k) ? this.attrs.get(k) : null; + }, + setAttribute(k, v) { + this.attrs.set(k, v); + }, + append(...kids) { + for (const k of kids) { + k.parent = this; + this.children.push(k); + } + }, + appendChild(k) { + this.append(k); + return k; + }, + contains(other) { + return other === this || this.children.some(c => c.contains && c.contains(other)); + }, + remove() { + if (this.parent) this.parent.children = this.parent.children.filter(c => c !== this); + this.isConnected = false; + }, + querySelectorAll(sel) { + const wanted = sel.split(',').map(s => s.trim()); + const out = []; + const walk = n => { + for (const c of n.children) { + const hit = wanted.some(w => w.startsWith(c.tagName.toLowerCase())); + if (hit && !c.disabled) out.push(c); + walk(c); + } + }; + walk(this); + return out; + }, + }); + + const body = el('div'); + const doc = { + get activeElement() { + return active; + }, + body, + createElement: el, + addEventListener(type, fn) { + if (!listeners.has(type)) listeners.set(type, []); + listeners.get(type).push(fn); + }, + removeEventListener(type, fn) { + const l = listeners.get(type) || []; + const at = l.indexOf(fn); + if (at !== -1) l.splice(at, 1); + }, + _fire(type, event) { + for (const fn of [...(listeners.get(type) || [])]) fn(event); + }, + }; + + globalThis.document = doc; + globalThis.getComputedStyle = () => ({ visibility: 'visible', display: 'block', position: 'static' }); + return { el, doc, body, setActive: n => (active = n), getActive: () => active }; +} + +async function withDom(fn) { + const dom = makeDom(); + const mod = await import('../js/modal.js'); + try { + return await fn(mod, dom); + } finally { + delete globalThis.document; + delete globalThis.getComputedStyle; + } +} + +const overlay = dom => dom.body.children[dom.body.children.length - 1]; +const buttons = box => box.children[2].children; + +/* A click reports the nearest ancestor of where it went down and where it came + up, so a selection dragged out of the dialog arrives on the overlay. */ +function clickFrom(ov, downTarget, upTarget) { + ov.onmousedown({ target: downTarget }); + ov.onclick({ target: upTarget }); +} + +test('the backdrop dismisses when the click both starts and ends on it', async () => { + await withDom(({ openModal }, dom) => { + let closed = 0; + openModal({ title: 'T', onClose: () => closed++ }); + const ov = overlay(dom); + clickFrom(ov, ov, ov); + assert.equal(closed, 1); + assert.equal(dom.body.children.includes(ov), false, 'the overlay is gone'); + }); +}); + +/* Selecting a line of the preview and releasing past the edge of the dialog. + Dismissing there discards an import the person was still reading. */ +test('a selection released on the backdrop does not dismiss', async () => { + await withDom(({ openModal }, dom) => { + let closed = 0; + const m = openModal({ title: 'T', onClose: () => closed++ }); + const ov = overlay(dom); + clickFrom(ov, m.body, ov); + assert.equal(closed, 0); + assert.equal(dom.body.children.includes(ov), true, 'the dialog is still open'); + }); +}); + +test('a click inside the dialog does not dismiss', async () => { + await withDom(({ openModal }, dom) => { + let closed = 0; + const m = openModal({ title: 'T', onClose: () => closed++ }); + clickFrom(overlay(dom), m.body, m.body); + assert.equal(closed, 0); + }); +}); + +/* The dialog body holds text that nothing can focus, so a click on it leaves + focus on the page. Escape has to work from there. */ +test('Escape closes after a click on text inside the dialog', async () => { + await withDom(({ openModal }, dom) => { + let closed = 0; + const m = openModal({ title: 'T', onClose: () => closed++ }); + m.addAction('OK', 'bp sm'); + m.focus(); + dom.setActive(dom.body); + dom.doc._fire('keydown', { key: 'Escape', preventDefault() {} }); + assert.equal(closed, 1); + }); +}); + +test('close is safe to call twice, so two dismissal routes answer once', async () => { + await withDom(({ openModal }, dom) => { + let closed = 0; + const m = openModal({ title: 'T', onClose: () => closed++ }); + m.close(); + m.close(); + assert.equal(closed, 1); + }); +}); + +test('arming the trap again releases the one before it', async () => { + await withDom(({ openModal }, dom) => { + let closed = 0; + const m = openModal({ title: 'T', onClose: () => closed++ }); + const a = m.addAction('OK', 'bp sm'); + m.focus(a); + m.focus(a); + dom.doc._fire('keydown', { key: 'Escape', preventDefault() {} }); + assert.equal(closed, 1, 'an orphaned trap would close it a second time'); + }); +}); + +test('confirmModal resolves true on the confirming action and false otherwise', async () => { + await withDom(async ({ confirmModal }, dom) => { + const yes = confirmModal({ title: 'T', body: dom.el('p'), confirmLabel: 'Go', cancelLabel: 'No' }); + const box = overlay(dom).children[0]; + buttons(box)[1].onclick(); + assert.equal(await yes, true); + + const no = confirmModal({ title: 'T', body: dom.el('p'), confirmLabel: 'Go', cancelLabel: 'No' }); + buttons(overlay(dom).children[0])[0].onclick(); + assert.equal(await no, false); + }); +}); + +/* Dismissing is not an answer. The caller treats false as Cancel, which is the + safe reading for a destructive action. */ +test('confirmModal resolves false when the backdrop dismisses it', async () => { + await withDom(async ({ confirmModal }, dom) => { + const answer = confirmModal({ title: 'T', body: dom.el('p'), confirmLabel: 'Go', cancelLabel: 'No' }); + const ov = overlay(dom); + clickFrom(ov, ov, ov); + assert.equal(await answer, false); + }); +}); + +test('promptModal returns the trimmed value and refuses an empty one', async () => { + await withDom(async ({ promptModal }, dom) => { + const answer = promptModal({ title: 'T', label: 'Name', confirmLabel: 'Save', cancelLabel: 'No' }); + const box = overlay(dom).children[0]; + const input = box.children[1].children[0].children[1]; + + input.value = ' '; + buttons(box)[1].onclick(); + assert.equal(dom.body.children.includes(overlay(dom)), true, 'an empty value keeps the dialog open'); + + input.value = ' Media '; + buttons(box)[1].onclick(); + assert.equal(await answer, 'Media'); + }); +}); + +test('promptModal resolves null when it is cancelled', async () => { + await withDom(async ({ promptModal }, dom) => { + const answer = promptModal({ title: 'T', label: 'Name', confirmLabel: 'Save', cancelLabel: 'No' }); + buttons(overlay(dom).children[0])[0].onclick(); + assert.equal(await answer, null); + }); +}); + +test('two open dialogs label their headings apart', async () => { + await withDom(({ openModal }, dom) => { + const a = openModal({ title: 'One' }); + const b = openModal({ title: 'Two' }); + assert.notEqual(a.box.getAttribute('aria-labelledby'), b.box.getAttribute('aria-labelledby')); + }); +}); diff --git a/ui/test/yaml-lite.test.mjs b/ui/test/yaml-lite.test.mjs index af31ed4c..66fb63b4 100644 --- a/ui/test/yaml-lite.test.mjs +++ b/ui/test/yaml-lite.test.mjs @@ -199,3 +199,53 @@ test('brackets with contents still refuse, since that is where guessing starts', assert.throws(() => parseYaml('tags: [a, b]\n'), /flow collections/); assert.throws(() => parseYaml('opts: { a: 1 }\n'), /flow collections/); }); + +/* A flow collection written as a list element used to reach the key matcher + first, which found the brace and the first key and built a mapping out of + them. The whole element parsed into something that was never in the file. */ +test('a flow collection refuses in a list too, not only after a key', () => { + assert.throws(() => parseYaml('sections:\n - {name: Apps, items: []}\n'), /flow collections/); + assert.throws(() => parseYaml('sections:\n - [a, b]\n'), /flow collections/); + assert.deepEqual(plain(parseYaml('sections:\n - {}\n - []\n')), { sections: [{}, []] }); +}); + +test('a list inside a list line refuses rather than reading as text', () => { + assert.throws(() => parseYaml('a:\n - - x\n'), /sequence inside a sequence/); +}); + +/* "*arr" is what a homelab calls the Sonarr and Radarr stack, and description + is an ordinary Homepage field. Refusing these refused valid files. */ +test('& and * inside a value are text, not an anchor', () => { + assert.equal(parseYaml('description: The *arr stack\n').description, 'The *arr stack'); + assert.equal(parseYaml('description: Movies &TV\n').description, 'Movies &TV'); + assert.equal(parseYaml('cron: "*/5 * * * *"\n').cron, '*/5 * * * *'); + assert.equal(parseYaml('- name: Plex **HD**\n')[0].name, 'Plex **HD**'); +}); + +test('an anchor where a node begins still refuses', () => { + assert.throws(() => parseYaml('base: &defaults\n a: 1\n'), /anchors and aliases/); + assert.throws(() => parseYaml('a: *ref\n'), /anchors and aliases/); + assert.throws(() => parseYaml('&defaults key: 1\n'), /anchors and aliases/); + assert.throws(() => parseYaml('a:\n - &ref x\n'), /anchors and aliases/); +}); + +/* Hand-aligned configs pad the dash. The continuation keys then sit one column + further in, and assuming a fixed offset threw on the whole file. */ +test('a list element keeps its own key column, however the dash is padded', () => { + const one = plain(parseYaml('sections:\n - name: Apps\n icon: box\n')); + assert.deepEqual(plain(parseYaml('sections:\n - name: Apps\n icon: box\n')), one); + assert.deepEqual(plain(parseYaml('sections:\n - name: Apps\n icon: box\n')), one); +}); + +test('a double-quoted key decodes its escapes the way a value does', () => { + assert.deepEqual(Object.keys(parseYaml('"a\\nb": 1\n')), ['a\nb']); +}); + +/* Every trailing newline, which is the whole difference between "keep" and the + default. Stripping the blank lines before the fold left nothing to keep. */ +test('the keep indicator keeps more than one trailing newline', () => { + assert.equal(parseYaml('a: |+\n x\n\n\nb: 1\n').a, 'x\n\n\n'); + assert.equal(parseYaml('a: |+\n').a, ''); + assert.equal(parseYaml('a: |\n x\n\n\nb: 1\n').a, 'x\n'); + assert.equal(parseYaml('a: |-\n x\n\n\nb: 1\n').a, 'x'); +});