Frontend tail: keyboard access, idempotence, input edge cases
- gallery.js: math/score focus overlays are keyboard-activatable (role=button, tabindex, Enter/Space) and focus return on close lands on a focusable trigger (AUDIT §5.7) - annotations.js: marks are focusable; Enter/Space pins the tooltip with focus moved to its Delete button, Escape dismisses — the delete affordance is finally reachable without a mouse (§5.7) - transclude.js: nested transclusions resolve (depth-capped at 3, with ancestor-chain cycle rejection rendering the existing error style); collapse.js reinit is idempotent via data-collapse-bound (§5.7) - copy.js excludes the button label from code-less <pre> copies; score-reader.js stops rewriting plain loads to ?p=1; search-filters treats non-numeric threshold input as inactive instead of a match-everything >=0 filter; selection-popup no longer re-summons the toolbar while typing capitals in the annotation picker (§5.8) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
9f61ce5949
commit
23bc2d0dc1
|
|
@ -12,6 +12,8 @@
|
||||||
var STORAGE_KEY = 'site-annotations';
|
var STORAGE_KEY = 'site-annotations';
|
||||||
var tooltip = null;
|
var tooltip = null;
|
||||||
var tooltipTimer = null;
|
var tooltipTimer = null;
|
||||||
|
var tooltipPinned = false; /* keyboard-opened: blur must not dismiss */
|
||||||
|
var tooltipMark = null; /* mark that opened the tooltip, for focus return */
|
||||||
|
|
||||||
/* ------------------------------------------------------------------
|
/* ------------------------------------------------------------------
|
||||||
Storage
|
Storage
|
||||||
|
|
@ -148,6 +150,18 @@
|
||||||
|
|
||||||
tooltip.addEventListener('mouseenter', function () { clearTimeout(tooltipTimer); });
|
tooltip.addEventListener('mouseenter', function () { clearTimeout(tooltipTimer); });
|
||||||
tooltip.addEventListener('mouseleave', function () { hideTooltip(false); });
|
tooltip.addEventListener('mouseleave', function () { hideTooltip(false); });
|
||||||
|
|
||||||
|
/* Keyboard flow: Escape closes a pinned tooltip and returns focus
|
||||||
|
to its mark; tabbing out of the tooltip dismisses it. */
|
||||||
|
tooltip.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
hideTooltip(true);
|
||||||
|
if (tooltipMark) tooltipMark.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tooltip.addEventListener('focusout', function (e) {
|
||||||
|
if (!tooltip.contains(e.relatedTarget)) hideTooltip(false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Defer to the shared utility (loaded synchronously from
|
/* Defer to the shared utility (loaded synchronously from
|
||||||
|
|
@ -159,6 +173,8 @@
|
||||||
|
|
||||||
function showTooltip(mark, ann) {
|
function showTooltip(mark, ann) {
|
||||||
clearTimeout(tooltipTimer);
|
clearTimeout(tooltipTimer);
|
||||||
|
tooltipPinned = false;
|
||||||
|
tooltipMark = mark;
|
||||||
|
|
||||||
var note = ann.note || '';
|
var note = ann.note || '';
|
||||||
var created = ann.created ? new Date(ann.created).toLocaleDateString() : '';
|
var created = ann.created ? new Date(ann.created).toLocaleDateString() : '';
|
||||||
|
|
@ -197,6 +213,7 @@
|
||||||
|
|
||||||
function hideTooltip(immediate) {
|
function hideTooltip(immediate) {
|
||||||
clearTimeout(tooltipTimer);
|
clearTimeout(tooltipTimer);
|
||||||
|
tooltipPinned = false;
|
||||||
if (immediate) {
|
if (immediate) {
|
||||||
if (tooltip) tooltip.classList.remove('is-visible');
|
if (tooltip) tooltip.classList.remove('is-visible');
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -212,6 +229,28 @@
|
||||||
showTooltip(mark, ann);
|
showTooltip(mark, ann);
|
||||||
});
|
});
|
||||||
mark.addEventListener('mouseleave', function () { hideTooltip(false); });
|
mark.addEventListener('mouseleave', function () { hideTooltip(false); });
|
||||||
|
|
||||||
|
/* Keyboard: focus mirrors hover; Enter/Space pins the tooltip and
|
||||||
|
moves focus to its Delete button; Escape dismisses. */
|
||||||
|
mark.setAttribute('tabindex', '0');
|
||||||
|
mark.addEventListener('focus', function () {
|
||||||
|
clearTimeout(tooltipTimer);
|
||||||
|
showTooltip(mark, ann);
|
||||||
|
});
|
||||||
|
mark.addEventListener('blur', function () {
|
||||||
|
if (!tooltipPinned) hideTooltip(false);
|
||||||
|
});
|
||||||
|
mark.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
showTooltip(mark, ann);
|
||||||
|
tooltipPinned = true;
|
||||||
|
var del = tooltip.querySelector('.ann-tooltip-delete');
|
||||||
|
if (del) del.focus();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
hideTooltip(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------
|
/* ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@
|
||||||
var store = window.lnUtils && window.lnUtils.safeStorage;
|
var store = window.lnUtils && window.lnUtils.safeStorage;
|
||||||
|
|
||||||
function initHeading(heading) {
|
function initHeading(heading) {
|
||||||
|
// Idempotence guard: reinitCollapse may be called more than once on
|
||||||
|
// the same container — never re-wrap a section or stack toggle
|
||||||
|
// buttons (matches the popups.js/sidenotes.js convention).
|
||||||
|
if (heading.dataset.collapseBound === '1') return;
|
||||||
|
|
||||||
var level = parseInt(heading.tagName[1], 10);
|
var level = parseInt(heading.tagName[1], 10);
|
||||||
var content = [];
|
var content = [];
|
||||||
var node = heading.nextElementSibling;
|
var node = heading.nextElementSibling;
|
||||||
|
|
@ -28,6 +33,7 @@
|
||||||
node = node.nextElementSibling;
|
node = node.nextElementSibling;
|
||||||
}
|
}
|
||||||
if (!content.length) return;
|
if (!content.length) return;
|
||||||
|
heading.dataset.collapseBound = '1';
|
||||||
|
|
||||||
// Wrap collected nodes in a .section-body div.
|
// Wrap collected nodes in a .section-body div.
|
||||||
var wrapper = document.createElement('div');
|
var wrapper = document.createElement('div');
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,18 @@
|
||||||
btn.setAttribute('aria-label', 'Copy code to clipboard');
|
btn.setAttribute('aria-label', 'Copy code to clipboard');
|
||||||
|
|
||||||
btn.addEventListener('click', function () {
|
btn.addEventListener('click', function () {
|
||||||
var text = pre.querySelector('code')
|
var code = pre.querySelector('code');
|
||||||
? pre.querySelector('code').innerText
|
var text;
|
||||||
: pre.innerText;
|
if (code) {
|
||||||
|
text = code.innerText;
|
||||||
|
} else {
|
||||||
|
/* Code-less <pre>: clone and strip the injected button so
|
||||||
|
its label is not copied along with the content. */
|
||||||
|
var clone = pre.cloneNode(true);
|
||||||
|
var cloneBtn = clone.querySelector('.copy-btn');
|
||||||
|
if (cloneBtn) cloneBtn.remove();
|
||||||
|
text = clone.innerText;
|
||||||
|
}
|
||||||
|
|
||||||
navigator.clipboard.writeText(text).then(function () {
|
navigator.clipboard.writeText(text).then(function () {
|
||||||
btn.textContent = 'copied';
|
btn.textContent = 'copied';
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,21 @@
|
||||||
return exhibit.dataset.exhibitCaption || '';
|
return exhibit.dataset.exhibitCaption || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Make an exhibit wrapper keyboard-operable: role=button, tabindex,
|
||||||
|
and Enter/Space sharing the click path. closeOverlay()'s focus
|
||||||
|
return relies on the wrapper being focusable. */
|
||||||
|
function bindActivation(el, activate) {
|
||||||
|
el.setAttribute('role', 'button');
|
||||||
|
el.setAttribute('tabindex', '0');
|
||||||
|
el.addEventListener('click', activate);
|
||||||
|
el.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
activate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function discoverFocusableMath(markdownBody) {
|
function discoverFocusableMath(markdownBody) {
|
||||||
markdownBody.querySelectorAll('.katex-display').forEach(function (katexEl) {
|
markdownBody.querySelectorAll('.katex-display').forEach(function (katexEl) {
|
||||||
var source = getSource(katexEl);
|
var source = getSource(katexEl);
|
||||||
|
|
@ -118,8 +133,8 @@
|
||||||
};
|
};
|
||||||
focusables.push(entry);
|
focusables.push(entry);
|
||||||
|
|
||||||
/* Click anywhere on the wrapper opens the overlay */
|
/* Click or Enter/Space anywhere on the wrapper opens the overlay */
|
||||||
wrapper.addEventListener('click', function () {
|
bindActivation(wrapper, function () {
|
||||||
openOverlay(focusables.indexOf(entry));
|
openOverlay(focusables.indexOf(entry));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -151,7 +166,7 @@
|
||||||
};
|
};
|
||||||
focusables.push(entry);
|
focusables.push(entry);
|
||||||
|
|
||||||
figEl.addEventListener('click', function () {
|
bindActivation(figEl, function () {
|
||||||
openOverlay(focusables.indexOf(entry));
|
openOverlay(focusables.indexOf(entry));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@
|
||||||
|
|
||||||
/* Read ?p= from the query string for deep linking. */
|
/* Read ?p= from the query string for deep linking. */
|
||||||
var qs = new URLSearchParams(window.location.search);
|
var qs = new URLSearchParams(window.location.search);
|
||||||
|
/* Keep the canonical URL clean on plain loads: only sync ?p= back to
|
||||||
|
the URL when one was already present or the user navigates. */
|
||||||
|
var syncUrl = qs.has('p');
|
||||||
var initPage = parseInt(qs.get('p'), 10);
|
var initPage = parseInt(qs.get('p'), 10);
|
||||||
if (!isNaN(initPage) && initPage >= 1 && initPage <= pageCount) {
|
if (!isNaN(initPage) && initPage >= 1 && initPage <= pageCount) {
|
||||||
currentPage = initPage;
|
currentPage = initPage;
|
||||||
|
|
@ -47,7 +50,7 @@
|
||||||
|
|
||||||
/* Replace URL so the page is bookmarkable at the current position.
|
/* Replace URL so the page is bookmarkable at the current position.
|
||||||
The back button still returns to the landing page. */
|
The back button still returns to the landing page. */
|
||||||
history.replaceState(null, '', '?p=' + currentPage);
|
if (syncUrl) history.replaceState(null, '', '?p=' + currentPage);
|
||||||
|
|
||||||
/* Preload the adjacent pages for smooth turning. */
|
/* Preload the adjacent pages for smooth turning. */
|
||||||
if (currentPage > 1) new Image().src = pages[currentPage - 2];
|
if (currentPage > 1) new Image().src = pages[currentPage - 2];
|
||||||
|
|
@ -132,4 +135,5 @@
|
||||||
------------------------------------------------------------------ */
|
------------------------------------------------------------------ */
|
||||||
|
|
||||||
navigate(currentPage);
|
navigate(currentPage);
|
||||||
|
syncUrl = true; /* any later navigate() is a user action — sync from here on */
|
||||||
}());
|
}());
|
||||||
|
|
|
||||||
|
|
@ -273,7 +273,12 @@
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.addEventListener('input', function () {
|
el.addEventListener('input', function () {
|
||||||
var v = el.value.trim();
|
var v = el.value.trim();
|
||||||
state[field] = v !== '' ? Math.max(0, Math.min(100, parseInt(v, 10) || 0)) : null;
|
var n = parseInt(v, 10);
|
||||||
|
/* Non-numeric input deactivates the filter (null) rather
|
||||||
|
than coercing to an always-matching >= 0 threshold. */
|
||||||
|
state[field] = (v !== '' && !isNaN(n))
|
||||||
|
? Math.max(0, Math.min(100, n))
|
||||||
|
: null;
|
||||||
loadMeta().then(applyFilters);
|
loadMeta().then(applyFilters);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeyUp(e) {
|
function onKeyUp(e) {
|
||||||
|
/* Typing capitals in the annotation picker's note input (or any
|
||||||
|
other editable field) releases Shift — don't re-summon the
|
||||||
|
toolbar over the UI the user is typing into. */
|
||||||
|
var t = e.target;
|
||||||
|
if (t && t.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
if (popup.contains(t)) return;
|
||||||
|
if (picker && picker.contains(t)) return;
|
||||||
|
if (t.isContentEditable || t.closest('input, textarea')) return;
|
||||||
|
}
|
||||||
if (e.shiftKey || e.key === 'End' || e.key === 'Home') {
|
if (e.shiftKey || e.key === 'End' || e.key === 'Home') {
|
||||||
clearTimeout(showTimer);
|
clearTimeout(showTimer);
|
||||||
showTimer = setTimeout(tryShow, SHOW_DELAY);
|
showTimer = setTimeout(tryShow, SHOW_DELAY);
|
||||||
|
|
|
||||||
|
|
@ -108,11 +108,26 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadTransclusion(el) {
|
/* Nested transclusion limits: ancestors carries the chain of srcs
|
||||||
|
* currently being expanded (cycle guard — a self-transcluding page
|
||||||
|
* must not loop), and MAX_DEPTH caps pathological nesting. */
|
||||||
|
var MAX_DEPTH = 3;
|
||||||
|
|
||||||
|
function loadTransclusion(el, depth, ancestors) {
|
||||||
|
depth = depth || 0;
|
||||||
|
ancestors = ancestors || [];
|
||||||
|
|
||||||
var src = el.dataset.src;
|
var src = el.dataset.src;
|
||||||
var section = el.dataset.section || null;
|
var section = el.dataset.section || null;
|
||||||
if (!src) return;
|
if (!src) return;
|
||||||
|
|
||||||
|
if (depth >= MAX_DEPTH || ancestors.indexOf(src) !== -1) {
|
||||||
|
el.classList.add('transclude--error');
|
||||||
|
el.textContent = '[transclusion omitted (cycle or depth limit): '
|
||||||
|
+ src + (section ? '#' + section : '') + ']';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
el.classList.add('transclude--loading');
|
el.classList.add('transclude--loading');
|
||||||
|
|
||||||
fetchPage(src)
|
fetchPage(src)
|
||||||
|
|
@ -138,6 +153,14 @@
|
||||||
el.classList.replace('transclude--loading', 'transclude--loaded');
|
el.classList.replace('transclude--loading', 'transclude--loaded');
|
||||||
el.appendChild(wrapper);
|
el.appendChild(wrapper);
|
||||||
|
|
||||||
|
/* The fetched page may itself contain transclusion
|
||||||
|
placeholders — process them too, extending the
|
||||||
|
ancestor chain for cycle/depth guarding. */
|
||||||
|
var chain = ancestors.concat(src);
|
||||||
|
wrapper.querySelectorAll('div.transclude').forEach(function (nested) {
|
||||||
|
loadTransclusion(nested, depth + 1, chain);
|
||||||
|
});
|
||||||
|
|
||||||
reinitFragment(el);
|
reinitFragment(el);
|
||||||
})
|
})
|
||||||
.catch(function (err) {
|
.catch(function (err) {
|
||||||
|
|
@ -147,6 +170,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
document.querySelectorAll('div.transclude').forEach(loadTransclusion);
|
document.querySelectorAll('div.transclude').forEach(function (el) {
|
||||||
|
loadTransclusion(el);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}());
|
}());
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue