diff --git a/accessibility/keyboardui.js b/accessibility/keyboardui.js index 4d81ecfc..259cec10 100644 --- a/accessibility/keyboardui.js +++ b/accessibility/keyboardui.js @@ -5,13 +5,22 @@ 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). Portrait phones are narrow too but -// tall enough for the docked panel to still fit, so landscape is required. +// 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; +// 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; + +// 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; + return height > 0 && height < needed; +}; + // Area menu accessed with Ctrl + B to quickly skip to // different areas on the interface @@ -675,7 +684,8 @@ const InfoPanel = { this._body = document.getElementById('info-panel-body'); }, - register(id, label) { + // 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}`; btn.className = 'info-tab-btn bigbutton'; @@ -683,7 +693,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'; @@ -715,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(); }, @@ -738,125 +751,40 @@ 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 info-panel tabs; mixers must set _modalTitleId, _tabBtnId, _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', () => { + // 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 (isNarrowLayout() && !this._modalActive) this.enterModal(); - else if (!isNarrowLayout() && 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. + if (this.shouldBeModal()) this.enterModal(); + else if (this._modalActive) { + // 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; + } } - }); - }, - - 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') { + // rAF-deferred: mutating the DOM inside the callback (enterModal reparents) 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 +807,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 +849,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,10 +894,123 @@ 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) => { - 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' }); @@ -1097,28 +1138,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 +1239,7 @@ const PlayerPanel = { this.renderContent(); this.previousFocus = document.activeElement; InfoPanel.activate('player'); + if (this.shouldBeModal()) this.enterModal(); }, refreshTranslations() { @@ -1208,6 +1247,7 @@ const PlayerPanel = { }, hide() { + this.exitModal(); this.previousFocus?.focus(); this.previousFocus = null; InfoPanel.deactivate('player'); @@ -1219,11 +1259,26 @@ 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(); + // 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' }); + } + 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..6d0d755a 100644 --- a/main/view.js +++ b/main/view.js @@ -208,6 +208,14 @@ function resizeCanvas() { canvas.style.width = `${Math.round(newWidth)}px`; canvas.style.height = `${Math.round(newHeight)}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. if (flock.engine) return; diff --git a/style.css b/style.css index 1f15b2b7..79ee11ea 100644 --- a/style.css +++ b/style.css @@ -1112,21 +1112,38 @@ 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 - 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: children (the tab bar) still need to render, but shouldn't take a column in #canvasArea's row layout. */ aside#info-panel { + display: contents; + } + + #info-panel-body { display: none; } + /* 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 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))); + z-index: 1001; + margin-bottom: 0; + justify-content: flex-end; + } + + /* #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; + } + .bottombar-flocklink { position: absolute; right: 12px; 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); }); });