From ac23d5a33b246d8c6957d396ef20631194aa423f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gaute=20Fl=C3=A6gstad?= Date: Fri, 7 Nov 2025 15:41:43 +0100 Subject: [PATCH] feat(#50): :sparkles: scene is now more tabbable --- src/Accessibility/makeMenuTabbable.js | 162 ++++++++++++++++++++++++++ src/main.js | 1 - 2 files changed, 162 insertions(+), 1 deletion(-) diff --git a/src/Accessibility/makeMenuTabbable.js b/src/Accessibility/makeMenuTabbable.js index 1dfa94d..04821f3 100644 --- a/src/Accessibility/makeMenuTabbable.js +++ b/src/Accessibility/makeMenuTabbable.js @@ -7,6 +7,8 @@ export function makeMenuTabbable() { makePanelsTabbable() makeElevationControlTabbable() makeAcceptedFilteringTabbable() + makeObjectsTabbable() + makeObjectsDropdownsTabbable() makeMeasurementsTabbable() makeAppearancePanelTabbable() makeToolsPanelTabbable() @@ -328,3 +330,163 @@ function makeMeasurementsTabbable() { }) }) } + +/** + * Makes the Scene > Objects tree tabbable and keyboard operable + * - Tab to anchors + * - Enter/Space = select (click) + * - ArrowRight = open node, ArrowLeft = close node (uses jsTree if available) + * - ArrowUp/ArrowDown = move focus to previous/next visible item + * - Home/End = first/last visible item + */ +function makeObjectsTabbable() { + const install = (tree) => { + if (!tree) return + + // Ensure all anchors are tabbable + const setTabbable = (root) => { + root.querySelectorAll('a.jstree-anchor').forEach((a) => { + a.tabIndex = 0 + }) + } + setTabbable(tree) + + // Keep anchors tabbable when jsTree updates DOM + const mo = new MutationObserver((muts) => { + for (const m of muts) { + if (m.addedNodes && m.addedNodes.length) { + m.addedNodes.forEach((n) => { + if (n.nodeType === 1) setTabbable(n) + }) + } + } + }) + mo.observe(tree, { childList: true, subtree: true }) + + const getAnchors = () => + Array.from(tree.querySelectorAll('a.jstree-anchor')) + const focusMove = (from, dir) => { + const anchors = getAnchors() + const i = anchors.indexOf(from) + const next = anchors[i + dir] + if (next) next.focus() + } + const focusEdge = (toLast) => { + const anchors = getAnchors() + const target = toLast ? anchors[anchors.length - 1] : anchors[0] + if (target) target.focus() + } + + // Delegate key handling on anchors + tree.addEventListener('keydown', (e) => { + const a = e.target + if ( + !a || + !(a instanceof HTMLElement) || + !a.classList.contains('jstree-anchor') + ) + return + const li = a.closest('li') + const id = li && li.id + const $ = window.jQuery || window.$ + const inst = $ && $(tree).jstree ? $(tree).jstree(true) : null + + switch (e.key) { + case 'Enter': + case ' ': // space + e.preventDefault() + a.click() + break + case 'ArrowDown': + e.preventDefault() + focusMove(a, +1) + break + case 'ArrowUp': + e.preventDefault() + focusMove(a, -1) + break + case 'Home': + e.preventDefault() + focusEdge(false) + break + case 'End': + e.preventDefault() + focusEdge(true) + break + case 'ArrowRight': + e.preventDefault() + if (inst && id) { + inst.open_node(id) + } else if (li && li.classList.contains('jstree-closed')) { + const toggle = li.querySelector('.jstree-ocl') + if (toggle) + toggle.dispatchEvent(new MouseEvent('click', { bubbles: true })) + } + break + case 'ArrowLeft': + e.preventDefault() + if (inst && id) { + inst.close_node(id) + } else if (li && li.classList.contains('jstree-open')) { + const toggle = li.querySelector('.jstree-ocl') + if (toggle) + toggle.dispatchEvent(new MouseEvent('click', { bubbles: true })) + } + break + } + }) + } + + const tryInit = () => { + const tree = document.getElementById('scene_objects') + if (tree) { + install(tree) + return true + } + return false + } + + if (!tryInit()) { + // Wait for GUI to load + const obs = new MutationObserver(() => { + if (tryInit()) obs.disconnect() + }) + obs.observe(document.body, { childList: true, subtree: true }) + } +} + +/** + * Make dropdowns in Properties tabbable: focus the associated input/select when label is activated via keyboard. + */ +function makeObjectsDropdownsTabbable() { + const make = () => { + const container = document.getElementById('potree_menu') + if (!container) return false + + // Labels that point to inputs/selects + container.querySelectorAll('label[for]').forEach((lbl) => { + lbl.tabIndex = 0 + lbl.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + const id = lbl.getAttribute('for') + const target = id && document.getElementById(id) + if (target instanceof HTMLElement) target.focus() + } + }) + }) + + // Ensure selects are in tab order (usually default) and openable via keyboard focus + container.querySelectorAll('select').forEach((sel) => { + sel.tabIndex = 0 + }) + return true + } + + if (!make()) { + const obs = new MutationObserver(() => { + if (make()) obs.disconnect() + }) + obs.observe(document.body, { childList: true, subtree: true }) + } +} diff --git a/src/main.js b/src/main.js index 87aaafe..45714a4 100644 --- a/src/main.js +++ b/src/main.js @@ -21,7 +21,6 @@ async function init() { POTREE_POINTCLOUD_URLS, POTREE_SETTINGS ) - potreeViewer.addEventListener('camera_changed', updateText) setupRightClickListener(potreeViewer)