From 191fe532a3b88b96af29a0e3075b8d3959213fcd Mon Sep 17 00:00:00 2001 From: Laura Sach <5183697+lawsie@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:38:59 +0100 Subject: [PATCH 1/2] Move bar on landscape mobile --- accessibility/keyboardui.js | 315 +++++++++++++++++++++--------------- main/view.js | 4 + style.css | 37 ++++- 3 files changed, 223 insertions(+), 133 deletions(-) diff --git a/accessibility/keyboardui.js b/accessibility/keyboardui.js index 4d81ecfc..3fb7174b 100644 --- a/accessibility/keyboardui.js +++ b/accessibility/keyboardui.js @@ -7,11 +7,28 @@ import { focusToolboxRestoringCategory } from '../main/toolboxfocus.js'; // Matches the CSS `(max-width: 1024px) and (orientation: landscape)` breakpoint // where the info panel is hidden and its shortcuts panel must be shown as a -// modal instead of docked (see style.css). Portrait phones are narrow too but -// tall enough for the docked panel to still fit, so landscape is required. +// modal instead of docked (see style.css). const isNarrowLayout = () => window.matchMedia('(max-width: 1024px) and (orientation: landscape)').matches; +// Docking is only worth it if a couple of control rows fit; below that the panel +// scrolls one entry at a time (an iPhone SE in portrait leaves ~130px) and the +// modal reads better. Measured row height is ~40px per em of the panel's +// font-size control, on top of its 1em padding and header. +const MIN_DOCKED_ROWS = 2; +const PANEL_CHROME_HEIGHT = 65; +const ROW_HEIGHT_PER_EM = 40; + +// #info-panel-body is `flex: 1`, so this measures the space the layout allots it +// rather than its content, and stays valid while a panel is reparented out to +// modal. Height 0 means unmeasurable (display:none, jsdom) — isNarrowLayout() +// already covers the hidden case, so don't treat it as short here. +const isDockedAreaTooShort = (fontSize) => { + const height = document.getElementById('info-panel-body')?.offsetHeight ?? 0; + const needed = PANEL_CHROME_HEIGHT + MIN_DOCKED_ROWS * ROW_HEIGHT_PER_EM * fontSize; + return height > 0 && height < needed; +}; + // Area menu accessed with Ctrl + B to quickly skip to // different areas on the interface @@ -675,7 +692,10 @@ const InfoPanel = { this._body = document.getElementById('info-panel-body'); }, - register(id, label) { + // `owner` is the panel object. Route the tab through its toggle() so clicking + // the tab gets the same presentation logic (docked vs modal) as every other + // entry point; activate()/deactivate() alone only swap DOM classes. + register(id, label, owner) { const btn = document.createElement('button'); btn.id = `info-tab-btn-${id}`; btn.className = 'info-tab-btn bigbutton'; @@ -683,7 +703,7 @@ const InfoPanel = { btn.setAttribute('aria-selected', 'false'); btn.setAttribute('aria-controls', `info-tab-panel-${id}`); btn.textContent = label; - btn.addEventListener('click', () => this.toggle(id)); + btn.addEventListener('click', () => (owner ? owner.toggle() : this.toggle(id))); this._tablist.appendChild(btn); const divider = document.createElement('div'); divider.className = 'toolbar-divider'; @@ -738,125 +758,43 @@ const SHORTCUTS_FONT_SIZES = [0.8, 1.0, 1.2, 1.4, 1.6, 1.8]; const SHORTCUTS_FONT_SIZE_KEY = 'flock-shortcuts-font-size'; const SHORTCUTS_FONT_SIZE_DEFAULT = 1.2; -const ShortcutsPanel = { - panel: null, - previousFocus: null, - fontSize: - parseFloat(localStorage.getItem(SHORTCUTS_FONT_SIZE_KEY)) || SHORTCUTS_FONT_SIZE_DEFAULT, - - init() { - this.createPanel(); - this.setupListeners(); - window.flockShortcutsPanel = this; +// Modal presentation shared by the info-panel tabs. Mixing panels must set +// _modalTitleId, _tabBtnId and _closeLabelKey. +const ModalPanelBehaviour = { + shouldBeModal() { + return isNarrowLayout() || isDockedAreaTooShort(this.fontSize); + }, - // If the viewport crosses the breakpoint while the panel is open, switch it - // between docked and modal so it never ends up docked in a hidden panel. - window.addEventListener('resize', () => { + // Keep an open panel on the right side of the breakpoint so it never ends up + // docked in an info panel that is hidden or too short to read it in. The + // docked area also changes without a window resize — play mode hides the + // gizmo bar, the Canvas/Code toggle, the splitter — so observe the element + // itself, and keep the resize listener for the media-query flip (a panel + // going display:none has no box for ResizeObserver to report on). + watchDockedSpace() { + const reevaluate = () => { if (this.panel.classList.contains('hidden')) return; - if (isNarrowLayout() && !this._modalActive) this.enterModal(); - else if (!isNarrowLayout() && this._modalActive) { + if (this.shouldBeModal()) this.enterModal(); + else if (this._modalActive) { // exitModal() itself doesn't restore focus (unlike hide(), which calls // it and then does this) — without it, closing the modal this way (e.g. // rotating the device) can leave focus on the just-removed close button. this.exitModal(); this.previousFocus?.focus(); } - }); - }, - - adjustFontSize(delta) { - const sizes = SHORTCUTS_FONT_SIZES; - const idx = sizes.indexOf(this.fontSize); - const next = sizes[Math.max(0, Math.min(sizes.length - 1, idx + delta))]; - if (next === this.fontSize) return; - this.fontSize = next; - localStorage.setItem(SHORTCUTS_FONT_SIZE_KEY, next); - this.panel.querySelector('#shortcuts-list').style.fontSize = next + 'em'; - this.panel.querySelector('.shortcuts-decrease-btn').disabled = next === sizes[0]; - this.panel.querySelector('.shortcuts-increase-btn').disabled = next === sizes[sizes.length - 1]; - }, - - createPanel() { - const panel = InfoPanel.register('shortcuts', translate('shortcut_panel_title')); - const btn = document.getElementById('info-tab-btn-shortcuts'); - btn.setAttribute('aria-label', translate('shortcut_panel_title')); - btn.setAttribute('title', translate('shortcut_panel_title')); - btn.innerHTML = ``; - panel.innerHTML = ` -
-

-
- - - -
-
-
- `; - this.panel = panel; - const sizes = SHORTCUTS_FONT_SIZES; - const decreaseBtn = panel.querySelector('.shortcuts-decrease-btn'); - const increaseBtn = panel.querySelector('.shortcuts-increase-btn'); - decreaseBtn.disabled = this.fontSize === sizes[0]; - increaseBtn.disabled = this.fontSize === sizes[sizes.length - 1]; - decreaseBtn.addEventListener('click', () => this.adjustFontSize(-1)); - increaseBtn.addEventListener('click', () => this.adjustFontSize(1)); - panel.querySelector('#shortcuts-list').style.fontSize = this.fontSize + 'em'; - this.renderContent(); - }, - - renderContent() { - document - .getElementById('info-tab-btn-shortcuts') - .setAttribute('aria-label', translate('shortcut_panel_title')); - this.panel.querySelector('#shortcuts-panel-title').textContent = - translate('shortcut_panel_title'); - this.panel - .querySelector('.help-link-button') - .setAttribute('aria-label', translate('shortcut_panel_help_link')); - const container = this.panel.querySelector('#shortcuts-list'); - const groups = getShortcuts().reduce((acc, s) => { - (acc[s.category] ??= []).push(s); - return acc; - }, {}); - container.innerHTML = Object.entries(groups) - .map( - ([cat, items]) => ` -

${cat}

-
- ${items.map(({ label, keys }) => `
${label}
${formatKeys(keys)}
`).join('')} -
- ` - ) - .join(''); - }, - - show() { - this.renderContent(); - this.previousFocus = document.activeElement; - InfoPanel.activate('shortcuts'); - document.getElementById('shortcutsBtn')?.classList.add('active'); - if (isNarrowLayout()) this.enterModal(); - }, - - refreshTranslations() { - this.renderContent(); - }, + }; - hide() { - this.exitModal(); - this.previousFocus?.focus(); - this.previousFocus = null; - InfoPanel.deactivate('shortcuts'); - document.getElementById('shortcutsBtn')?.classList.remove('active'); - }, + window.addEventListener('resize', reevaluate); - toggle() { - this.panel.classList.contains('hidden') ? this.show() : this.hide(); + const dockedArea = document.getElementById('info-panel-body'); + if (dockedArea && typeof ResizeObserver !== 'undefined') { + // Deferred: enterModal() reparents the panel, and mutating the DOM inside + // the callback trips "ResizeObserver loop completed with undelivered + // notifications". + new ResizeObserver(() => requestAnimationFrame(reevaluate)).observe(dockedArea); + } }, - // --- Modal presentation (narrow layouts where the docked panel has no room) --- - // Reparent the panel to , mark it a dialog, inert the rest of the page // and trap focus. Reparenting is required so it escapes the info panel (which // is display:none in narrow mode) and the canvas area's overflow clipping. @@ -879,15 +817,15 @@ const ShortcutsPanel = { panel.classList.add('shortcuts-modal'); panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-modal', 'true'); - panel.setAttribute('aria-labelledby', 'shortcuts-panel-title'); + panel.setAttribute('aria-labelledby', this._modalTitleId); // Visible close control — there's no tab to click shut in modal mode, and // Escape/backdrop aren't discoverable (and Escape isn't available on touch). const closeBtn = document.createElement('button'); closeBtn.type = 'button'; closeBtn.className = 'bigbutton shortcuts-modal-close'; - closeBtn.setAttribute('aria-label', translate('shortcut_panel_close')); - closeBtn.setAttribute('title', translate('shortcut_panel_close')); + closeBtn.setAttribute('aria-label', translate(this._closeLabelKey)); + closeBtn.setAttribute('title', translate(this._closeLabelKey)); closeBtn.innerHTML = ''; closeBtn.addEventListener('click', () => this.hide()); panel.querySelector('.shortcuts-panel-controls')?.appendChild(closeBtn); @@ -921,7 +859,7 @@ const ShortcutsPanel = { panel.classList.remove('shortcuts-modal'); panel.setAttribute('role', 'tabpanel'); panel.removeAttribute('aria-modal'); - panel.setAttribute('aria-labelledby', 'info-tab-btn-shortcuts'); + panel.setAttribute('aria-labelledby', this._tabBtnId); this._closeBtn?.remove(); this._closeBtn = null; @@ -966,6 +904,115 @@ const ShortcutsPanel = { first.focus(); } }, +}; + +const ShortcutsPanel = { + ...ModalPanelBehaviour, + panel: null, + previousFocus: null, + _modalTitleId: 'shortcuts-panel-title', + _tabBtnId: 'info-tab-btn-shortcuts', + _closeLabelKey: 'shortcut_panel_close', + fontSize: + parseFloat(localStorage.getItem(SHORTCUTS_FONT_SIZE_KEY)) || SHORTCUTS_FONT_SIZE_DEFAULT, + + init() { + this.createPanel(); + this.setupListeners(); + this.watchDockedSpace(); + window.flockShortcutsPanel = this; + }, + + adjustFontSize(delta) { + const sizes = SHORTCUTS_FONT_SIZES; + const idx = sizes.indexOf(this.fontSize); + const next = sizes[Math.max(0, Math.min(sizes.length - 1, idx + delta))]; + if (next === this.fontSize) return; + this.fontSize = next; + localStorage.setItem(SHORTCUTS_FONT_SIZE_KEY, next); + this.panel.querySelector('#shortcuts-list').style.fontSize = next + 'em'; + this.panel.querySelector('.shortcuts-decrease-btn').disabled = next === sizes[0]; + this.panel.querySelector('.shortcuts-increase-btn').disabled = next === sizes[sizes.length - 1]; + }, + + createPanel() { + const panel = InfoPanel.register('shortcuts', translate('shortcut_panel_title'), this); + const btn = document.getElementById('info-tab-btn-shortcuts'); + btn.setAttribute('aria-label', translate('shortcut_panel_title')); + btn.setAttribute('title', translate('shortcut_panel_title')); + btn.innerHTML = ``; + panel.innerHTML = ` +
+

+
+ + + +
+
+
+ `; + this.panel = panel; + const sizes = SHORTCUTS_FONT_SIZES; + const decreaseBtn = panel.querySelector('.shortcuts-decrease-btn'); + const increaseBtn = panel.querySelector('.shortcuts-increase-btn'); + decreaseBtn.disabled = this.fontSize === sizes[0]; + increaseBtn.disabled = this.fontSize === sizes[sizes.length - 1]; + decreaseBtn.addEventListener('click', () => this.adjustFontSize(-1)); + increaseBtn.addEventListener('click', () => this.adjustFontSize(1)); + panel.querySelector('#shortcuts-list').style.fontSize = this.fontSize + 'em'; + this.renderContent(); + }, + + renderContent() { + document + .getElementById('info-tab-btn-shortcuts') + .setAttribute('aria-label', translate('shortcut_panel_title')); + this.panel.querySelector('#shortcuts-panel-title').textContent = + translate('shortcut_panel_title'); + this.panel + .querySelector('.help-link-button') + .setAttribute('aria-label', translate('shortcut_panel_help_link')); + const container = this.panel.querySelector('#shortcuts-list'); + const groups = getShortcuts().reduce((acc, s) => { + (acc[s.category] ??= []).push(s); + return acc; + }, {}); + container.innerHTML = Object.entries(groups) + .map( + ([cat, items]) => ` +

${cat}

+
+ ${items.map(({ label, keys }) => `
${label}
${formatKeys(keys)}
`).join('')} +
+ ` + ) + .join(''); + }, + + show() { + this.renderContent(); + this.previousFocus = document.activeElement; + InfoPanel.activate('shortcuts'); + document.getElementById('shortcutsBtn')?.classList.add('active'); + if (this.shouldBeModal()) this.enterModal(); + }, + + refreshTranslations() { + this.renderContent(); + }, + + hide() { + this.exitModal(); + this.previousFocus?.focus(); + this.previousFocus = null; + InfoPanel.deactivate('shortcuts'); + document.getElementById('shortcutsBtn')?.classList.remove('active'); + }, + + toggle() { + this.panel.classList.contains('hidden') ? this.show() : this.hide(); + }, setupListeners() { this.panel.addEventListener('keydown', (e) => { @@ -1097,28 +1144,25 @@ function getPlayerControls() { } // On-screen and gamepad counterpart to ShortcutsPanel: a second info panel tab. -// Docked-only — narrow landscape hides #info-panel outright (along with this -// tab), so there's no modal counterpart to fall back to. const PlayerPanel = { + ...ModalPanelBehaviour, panel: null, previousFocus: null, + _modalTitleId: 'player-panel-title', + _tabBtnId: 'info-tab-btn-player', + _closeLabelKey: 'close', fontSize: parseFloat(localStorage.getItem(SHORTCUTS_FONT_SIZE_KEY)) || SHORTCUTS_FONT_SIZE_DEFAULT, init() { this.createPanel(); this.setupListeners(); + this.watchDockedSpace(); window.flockPlayerPanel = this; - - // Rotating into narrow landscape hides the info panel mid-view, which would - // leave this panel marked active but invisible. - window.addEventListener('resize', () => { - if (isNarrowLayout() && !this.panel.classList.contains('hidden')) this.hide(); - }); }, createPanel() { - const panel = InfoPanel.register('player', translate('player_section_onscreen')); + const panel = InfoPanel.register('player', translate('player_section_onscreen'), this); const btn = document.getElementById('info-tab-btn-player'); btn.innerHTML = ``; panel.innerHTML = ` @@ -1201,6 +1245,7 @@ const PlayerPanel = { this.renderContent(); this.previousFocus = document.activeElement; InfoPanel.activate('player'); + if (this.shouldBeModal()) this.enterModal(); }, refreshTranslations() { @@ -1208,6 +1253,7 @@ const PlayerPanel = { }, hide() { + this.exitModal(); this.previousFocus?.focus(); this.previousFocus = null; InfoPanel.deactivate('player'); @@ -1219,11 +1265,22 @@ const PlayerPanel = { setupListeners() { this.panel.addEventListener('keydown', (e) => { - if (e.key !== 'Escape') return; - e.preventDefault(); - e.stopPropagation(); - this.hide(); - document.getElementById('info-tab-btn-player')?.focus(); + const scroller = document.getElementById('info-panel-body'); + if (e.key === 'ArrowUp') { + e.preventDefault(); + scroller?.scrollBy({ top: -100, behavior: 'instant' }); + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + scroller?.scrollBy({ top: 100, behavior: 'instant' }); + } + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + this.hide(); + const tabBtn = document.getElementById('info-tab-btn-player'); + if (tabBtn?.offsetParent) tabBtn.focus(); + } }); }, }; diff --git a/main/view.js b/main/view.js index a3ab40b4..c8f18c14 100644 --- a/main/view.js +++ b/main/view.js @@ -208,6 +208,10 @@ function resizeCanvas() { canvas.style.width = `${Math.round(newWidth)}px`; canvas.style.height = `${Math.round(newHeight)}px`; + // Landscape parks the info-panel tabs in the strip beside the canvas; they're + // position:fixed and so can't measure it, hence publishing the edge here. + document.documentElement.style.setProperty('--canvas-width', `${Math.round(newWidth)}px`); + // The engine owns the buffer and renders at devicePixelRatio; sizing it to // CSS px here leaves it 1x until engine.resize(), drawing GUI px oversized. if (flock.engine) return; diff --git a/style.css b/style.css index 1f15b2b7..747e50a9 100644 --- a/style.css +++ b/style.css @@ -1115,18 +1115,47 @@ button { /* Narrow AND short: landscape at low resolutions (e.g. small laptop/tablet windows) where there's no room beside the canvas to dock the info panel's shortcuts panel. Portrait phones are narrow too, but tall enough that the - docked panel still fits, so this is scoped to landscape only. In this mode: - shortcuts open as a modal (.shortcuts-modal), the flock link moves into the - bottom bar, and Ctrl+B "area 5" drops out on its own (renderHighlights skips - 0-size nodes). Selector is `aside#info-panel` (not just `#info-panel`) so it + docked panel still fits, so this is scoped to landscape only. In this mode + both panels open as a modal (.shortcuts-modal) and the flock link moves into + the bottom bar. Selector is `aside#info-panel` (not just `#info-panel`) so it out-specifies the base `#info-panel` rule, declared later in the file, which would otherwise win the cascade. Must match the JS breakpoint used by `isNarrowLayout()` in accessibility/keyboardui.js. */ @media (max-width: 1024px) and (orientation: landscape) { + /* `contents`, not `none`: the tab bar is the only way to open either panel + here, so the panel must keep rendering its children — but must not take a + column of its own in #canvasArea's row layout. */ aside#info-panel { + display: contents; + } + + #info-panel-body { display: none; } + /* Park the tabs in the dead space below the docked gizmo block and above the + bottom bar. Fixed, so the height-bound canvas gives up nothing for them. + Qualified with the ancestor for the same cascade reason as above: the base + #info-panel-tabs rule sets background/border-top further down the file. */ + aside#info-panel #info-panel-tabs { + position: fixed; + /* Spans the strip beside the canvas, whose edge resizeCanvas() publishes as + --canvas-width. Falls back to hugging the right before the first resize. */ + left: var(--canvas-width, auto); + right: 0; + bottom: calc(52px + max(0px, env(safe-area-inset-bottom, 0px))); + z-index: 1001; + margin-bottom: 0; + justify-content: flex-end; + } + + /* #bottombar-flocklink is the visible copy in this layout (see index.html), + so keep this one out of the a11y tree rather than showing it twice. + !important because showCanvasView() in main/view.js sets display inline. */ + #info-panel-tabs #flocklink { + display: none !important; + } + .bottombar-flocklink { position: absolute; right: 12px; From 3c2596086c5d99c226409f8c4a84ec4785132d00 Mon Sep 17 00:00:00 2001 From: Laura Sach <5183697+lawsie@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:14:24 +0100 Subject: [PATCH 2/2] Rabbit complaints --- accessibility/keyboardui.js | 60 ++++++++++++++++++------------------- main/view.js | 10 +++++-- style.css | 30 ++++++------------- tests/keyboardui.test.js | 28 ++++++++++++++++- 4 files changed, 72 insertions(+), 56 deletions(-) diff --git a/accessibility/keyboardui.js b/accessibility/keyboardui.js index 3fb7174b..259cec10 100644 --- a/accessibility/keyboardui.js +++ b/accessibility/keyboardui.js @@ -5,24 +5,16 @@ import { SHORTCUTS_HELP_URL } from '../config.js'; import { stopCanvasKeyboardMode } from '../ui/canvas-utils.js'; import { focusToolboxRestoringCategory } from '../main/toolboxfocus.js'; -// Matches the CSS `(max-width: 1024px) and (orientation: landscape)` breakpoint -// where the info panel is hidden and its shortcuts panel must be shown as a -// modal instead of docked (see style.css). +// Must match the CSS breakpoint in style.css that hides the docked info panel. const isNarrowLayout = () => window.matchMedia('(max-width: 1024px) and (orientation: landscape)').matches; -// Docking is only worth it if a couple of control rows fit; below that the panel -// scrolls one entry at a time (an iPhone SE in portrait leaves ~130px) and the -// modal reads better. Measured row height is ~40px per em of the panel's -// font-size control, on top of its 1em padding and header. +// Measured: ~65px chrome plus ~40px per em per row; below 2 rows the modal reads better than a docked scroll. const MIN_DOCKED_ROWS = 2; const PANEL_CHROME_HEIGHT = 65; const ROW_HEIGHT_PER_EM = 40; -// #info-panel-body is `flex: 1`, so this measures the space the layout allots it -// rather than its content, and stays valid while a panel is reparented out to -// modal. Height 0 means unmeasurable (display:none, jsdom) — isNarrowLayout() -// already covers the hidden case, so don't treat it as short here. +// offsetHeight is 0 both when too short and when unmeasurable (hidden/jsdom); isNarrowLayout() already handles the hidden case. const isDockedAreaTooShort = (fontSize) => { const height = document.getElementById('info-panel-body')?.offsetHeight ?? 0; const needed = PANEL_CHROME_HEIGHT + MIN_DOCKED_ROWS * ROW_HEIGHT_PER_EM * fontSize; @@ -692,9 +684,7 @@ const InfoPanel = { this._body = document.getElementById('info-panel-body'); }, - // `owner` is the panel object. Route the tab through its toggle() so clicking - // the tab gets the same presentation logic (docked vs modal) as every other - // entry point; activate()/deactivate() alone only swap DOM classes. + // owner.toggle(), not activate()/deactivate(), so the tab gets the same docked/modal logic as every other entry point. register(id, label, owner) { const btn = document.createElement('button'); btn.id = `info-tab-btn-${id}`; @@ -735,6 +725,9 @@ const InfoPanel = { tab.btn.setAttribute('aria-selected', 'true'); tab.btn.classList.add('active'); tab.panel.classList.remove('hidden'); + // All tab panels share this scroll container, so switching tabs must + // reset it or the new tab opens pre-scrolled to the old tab's position. + this._body.scrollTop = 0; tab.panel.focus(); }, @@ -758,29 +751,28 @@ const SHORTCUTS_FONT_SIZES = [0.8, 1.0, 1.2, 1.4, 1.6, 1.8]; const SHORTCUTS_FONT_SIZE_KEY = 'flock-shortcuts-font-size'; const SHORTCUTS_FONT_SIZE_DEFAULT = 1.2; -// Modal presentation shared by the info-panel tabs. Mixing panels must set -// _modalTitleId, _tabBtnId and _closeLabelKey. +// Modal presentation shared by info-panel tabs; mixers must set _modalTitleId, _tabBtnId, _closeLabelKey. const ModalPanelBehaviour = { shouldBeModal() { return isNarrowLayout() || isDockedAreaTooShort(this.fontSize); }, - // Keep an open panel on the right side of the breakpoint so it never ends up - // docked in an info panel that is hidden or too short to read it in. The - // docked area also changes without a window resize — play mode hides the - // gizmo bar, the Canvas/Code toggle, the splitter — so observe the element - // itself, and keep the resize listener for the media-query flip (a panel - // going display:none has no box for ResizeObserver to report on). + // Resize listener catches the media-query flip; ResizeObserver catches docked-area size changes without a window resize (e.g. play mode). watchDockedSpace() { const reevaluate = () => { if (this.panel.classList.contains('hidden')) return; if (this.shouldBeModal()) this.enterModal(); else if (this._modalActive) { - // exitModal() itself doesn't restore focus (unlike hide(), which calls - // it and then does this) — without it, closing the modal this way (e.g. - // rotating the device) can leave focus on the just-removed close button. + // exitModal() reparents the panel, which blurs whatever was focused even if that element survives the move; refocus it in place rather than jumping to previousFocus, which is only for the removed close button. + const active = document.activeElement; + const activeSurvives = this.panel.contains(active) && active !== this._closeBtn; this.exitModal(); - this.previousFocus?.focus(); + if (activeSurvives) { + active.focus(); + } else { + this.previousFocus?.focus(); + this.previousFocus = null; + } } }; @@ -788,9 +780,7 @@ const ModalPanelBehaviour = { const dockedArea = document.getElementById('info-panel-body'); if (dockedArea && typeof ResizeObserver !== 'undefined') { - // Deferred: enterModal() reparents the panel, and mutating the DOM inside - // the callback trips "ResizeObserver loop completed with undelivered - // notifications". + // rAF-deferred: mutating the DOM inside the callback (enterModal reparents) trips "ResizeObserver loop completed with undelivered notifications". new ResizeObserver(() => requestAnimationFrame(reevaluate)).observe(dockedArea); } }, @@ -1016,7 +1006,11 @@ const ShortcutsPanel = { setupListeners() { this.panel.addEventListener('keydown', (e) => { - const scroller = document.getElementById('info-panel-body'); + // Modal mode reparents the panel to and makes it the scroll + // container itself; #info-panel-body only scrolls in docked mode. + const scroller = this._modalActive + ? this.panel + : document.getElementById('info-panel-body'); if (e.key === 'ArrowUp') { e.preventDefault(); scroller?.scrollBy({ top: -100, behavior: 'instant' }); @@ -1265,7 +1259,11 @@ const PlayerPanel = { setupListeners() { this.panel.addEventListener('keydown', (e) => { - const scroller = document.getElementById('info-panel-body'); + // Modal mode reparents the panel to and makes it the scroll + // container itself; #info-panel-body only scrolls in docked mode. + const scroller = this._modalActive + ? this.panel + : document.getElementById('info-panel-body'); if (e.key === 'ArrowUp') { e.preventDefault(); scroller?.scrollBy({ top: -100, behavior: 'instant' }); diff --git a/main/view.js b/main/view.js index c8f18c14..6d0d755a 100644 --- a/main/view.js +++ b/main/view.js @@ -208,9 +208,13 @@ function resizeCanvas() { canvas.style.width = `${Math.round(newWidth)}px`; canvas.style.height = `${Math.round(newHeight)}px`; - // Landscape parks the info-panel tabs in the strip beside the canvas; they're - // position:fixed and so can't measure it, hence publishing the edge here. - document.documentElement.style.setProperty('--canvas-width', `${Math.round(newWidth)}px`); + // position:fixed info-panel tabs (landscape) can't measure the canvas strip themselves, so publish its + // edge here. Uses the canvas's actual viewport-right edge, not newWidth alone, since the canvas can sit + // offset from the area's left edge (e.g. beside the gizmo toolbar in gizmosBesideCanvas() layouts). + document.documentElement.style.setProperty( + '--canvas-width', + `${Math.round(canvas.getBoundingClientRect().right)}px` + ); // The engine owns the buffer and renders at devicePixelRatio; sizing it to // CSS px here leaves it 1x until engine.resize(), drawing GUI px oversized. diff --git a/style.css b/style.css index 747e50a9..79ee11ea 100644 --- a/style.css +++ b/style.css @@ -1112,19 +1112,13 @@ button { } } -/* Narrow AND short: landscape at low resolutions (e.g. small laptop/tablet - windows) where there's no room beside the canvas to dock the info panel's - shortcuts panel. Portrait phones are narrow too, but tall enough that the - docked panel still fits, so this is scoped to landscape only. In this mode - both panels open as a modal (.shortcuts-modal) and the flock link moves into - the bottom bar. Selector is `aside#info-panel` (not just `#info-panel`) so it - out-specifies the base `#info-panel` rule, declared later in the file, which - would otherwise win the cascade. Must match the JS breakpoint used by - `isNarrowLayout()` in accessibility/keyboardui.js. */ +/* Landscape-only: at low resolutions there's no room beside the canvas to dock + the info panel (portrait is narrow too but tall enough to stay docked). Both + panels become modal and the flock link moves to the bottom bar. Must match + `isNarrowLayout()` in accessibility/keyboardui.js. `aside#info-panel`, not + `#info-panel`, is needed to out-specify the base rule further down the file. */ @media (max-width: 1024px) and (orientation: landscape) { - /* `contents`, not `none`: the tab bar is the only way to open either panel - here, so the panel must keep rendering its children — but must not take a - column of its own in #canvasArea's row layout. */ + /* contents, not none: children (the tab bar) still need to render, but shouldn't take a column in #canvasArea's row layout. */ aside#info-panel { display: contents; } @@ -1133,14 +1127,10 @@ button { display: none; } - /* Park the tabs in the dead space below the docked gizmo block and above the - bottom bar. Fixed, so the height-bound canvas gives up nothing for them. - Qualified with the ancestor for the same cascade reason as above: the base - #info-panel-tabs rule sets background/border-top further down the file. */ + /* Fixed in the dead space below the gizmo block and above the bottom bar, so the canvas keeps its full height; ancestor-qualified for the same cascade reason as above. */ aside#info-panel #info-panel-tabs { position: fixed; - /* Spans the strip beside the canvas, whose edge resizeCanvas() publishes as - --canvas-width. Falls back to hugging the right before the first resize. */ + /* Spans the canvas strip published as --canvas-width by resizeCanvas(); falls back to hugging the right edge before the first resize. */ left: var(--canvas-width, auto); right: 0; bottom: calc(52px + max(0px, env(safe-area-inset-bottom, 0px))); @@ -1149,9 +1139,7 @@ button { justify-content: flex-end; } - /* #bottombar-flocklink is the visible copy in this layout (see index.html), - so keep this one out of the a11y tree rather than showing it twice. - !important because showCanvasView() in main/view.js sets display inline. */ + /* #bottombar-flocklink is the visible copy here (index.html); !important overrides the inline display showCanvasView() sets in main/view.js. */ #info-panel-tabs #flocklink { display: none !important; } diff --git a/tests/keyboardui.test.js b/tests/keyboardui.test.js index c563fb48..532c2111 100644 --- a/tests/keyboardui.test.js +++ b/tests/keyboardui.test.js @@ -493,6 +493,7 @@ export function runKeyboardUiTests(flock) { const closeBtn = ShortcutsPanel.panel.querySelector('.shortcuts-modal-close'); closeBtn.focus(); expect(document.activeElement).to.equal(closeBtn); + const expectedFocus = ShortcutsPanel.previousFocus; window.matchMedia = () => ({ matches: false }); try { @@ -501,7 +502,32 @@ export function runKeyboardUiTests(flock) { window.matchMedia = saved; } expect(ShortcutsPanel._modalActive).to.equal(false); - expect(document.activeElement).to.equal(ShortcutsPanel.previousFocus); + expect(document.activeElement).to.equal(expectedFocus); + expect(ShortcutsPanel.previousFocus).to.equal(null); + }); + + it('leaves focus on a surviving control when exiting modal on resize', function () { + const saved = window.matchMedia; + window.matchMedia = () => ({ matches: true }); + try { + ShortcutsPanel.show(); + window.dispatchEvent(new Event('resize')); + } finally { + window.matchMedia = saved; + } + expect(ShortcutsPanel._modalActive).to.equal(true); + const increaseBtn = ShortcutsPanel.panel.querySelector('.shortcuts-increase-btn'); + increaseBtn.focus(); + expect(document.activeElement).to.equal(increaseBtn); + + window.matchMedia = () => ({ matches: false }); + try { + window.dispatchEvent(new Event('resize')); + } finally { + window.matchMedia = saved; + } + expect(ShortcutsPanel._modalActive).to.equal(false); + expect(document.activeElement).to.equal(increaseBtn); }); });