Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 148 additions & 7 deletions accessibility/keyboardui.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { translate } from '../main/translation.js';
import { SHORTCUTS_HELP_URL } from '../config.js';
import { stopCanvasKeyboardMode } from '../ui/canvas-utils.js';

// Matches the CSS `max-width: 1024px` breakpoint where the info panel is hidden
// and its shortcuts panel must be shown as a modal instead of docked.
const isNarrowLayout = () => window.matchMedia('(max-width: 1024px)').matches;

// Area menu accessed with Ctrl + B to quickly skip to
// different areas on the interface

Expand Down Expand Up @@ -33,12 +37,24 @@ const AreaManager = {

get effectiveAreas() {
const reloadBtn = document.getElementById('reload-btn');
if (reloadBtn?.isConnected) {
return this.areas.map((a) =>
a.label === '9' ? { selector: '#reload-btn', label: '9', name: 'Reload' } : a
);
}
return this.areas;
const reloadConnected = reloadBtn?.isConnected;
const narrow = isNarrowLayout();
return this.areas.map((a) => {
// Narrow mode hides the info panel, so hand area 5 to the pill toggle —
// a direct jump to the Canvas/Code switch instead of a dead target.
if (narrow && a.label === '5') {
return {
selector: '#viewToggle',
label: '5',
name: 'View switch',
focusSelector: '#canvasToggleBtn',
};
}
if (reloadConnected && a.label === '9') {
return { selector: '#reload-btn', label: '9', name: 'Reload' };
}
return a;
});
},

init() {
Expand Down Expand Up @@ -688,6 +704,14 @@ const ShortcutsPanel = {
this.createPanel();
this.setupListeners();
window.flockShortcutsPanel = this;

// 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', () => {
if (this.panel.classList.contains('hidden')) return;
if (isNarrowLayout() && !this._modalActive) this.enterModal();
else if (!isNarrowLayout() && this._modalActive) this.exitModal();
});
},

adjustFontSize(delta) {
Expand Down Expand Up @@ -762,13 +786,15 @@ const ShortcutsPanel = {
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');
Expand All @@ -779,6 +805,118 @@ const ShortcutsPanel = {
this.panel.classList.contains('hidden') ? this.show() : this.hide();
},

// --- Modal presentation (narrow layouts where the docked panel has no room) ---

// Reparent the panel to <body>, 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.
enterModal() {
if (this._modalActive) return;
this._modalActive = true;
const panel = this.panel;

const backdrop = document.createElement('div');
backdrop.className = 'shortcuts-modal-backdrop';
backdrop.addEventListener('pointerdown', () => this.hide());
document.body.appendChild(backdrop);
this._backdrop = backdrop;

// Remember the docked location so we can put it back on close.
this._panelHome = panel.parentNode;
this._panelNextSibling = panel.nextSibling;
document.body.appendChild(panel);

panel.classList.add('shortcuts-modal');
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-modal', 'true');
panel.setAttribute('aria-labelledby', 'shortcuts-panel-title');

// 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.innerHTML = '<span aria-hidden="true">X</span>';
closeBtn.addEventListener('click', () => this.hide());
panel.querySelector('.shortcuts-panel-controls')?.appendChild(closeBtn);
this._closeBtn = closeBtn;

// Make everything else inert so SR/keyboard focus can't leave the dialog.
this._inertStates = new Map();
document.querySelectorAll('body > *').forEach((el) => {
if (el === panel || el === backdrop) return;
this._inertStates.set(el, el.inert);
el.inert = true;
});

this._trapHandler = (e) => this.trapFocus(e);
panel.addEventListener('keydown', this._trapHandler);

requestAnimationFrame(() => panel.focus());
},

exitModal() {
if (!this._modalActive) return;
this._modalActive = false;
const panel = this.panel;

panel.removeEventListener('keydown', this._trapHandler);
this._trapHandler = null;

this._inertStates?.forEach((wasInert, el) => (el.inert = wasInert));
this._inertStates = null;

panel.classList.remove('shortcuts-modal');
panel.setAttribute('role', 'tabpanel');
panel.removeAttribute('aria-modal');
Comment thread
coderabbitai[bot] marked this conversation as resolved.
panel.setAttribute('aria-labelledby', 'info-tab-btn-shortcuts');

this._closeBtn?.remove();
this._closeBtn = null;

// Dock the panel back where it came from.
if (this._panelHome) {
this._panelHome.insertBefore(panel, this._panelNextSibling);
this._panelHome = null;
this._panelNextSibling = null;
}

this._backdrop?.remove();
this._backdrop = null;
},

focusableElements() {
return [
...this.panel.querySelectorAll(
'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])'
),
].filter((el) => el.offsetWidth > 0 || el.offsetHeight > 0);
},

trapFocus(e) {
if (e.key !== 'Tab') return;
// Keep the app-level Tab manager (input.js) out of the dialog.
e.stopPropagation();
const focusables = this.focusableElements();
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement;
if (!first) {
e.preventDefault();
this.panel.focus();
return;
}
if (e.shiftKey && (active === first || active === this.panel)) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
},

setupListeners() {
this.panel.addEventListener('keydown', (e) => {
const scroller = document.getElementById('info-panel-body');
Expand All @@ -794,7 +932,10 @@ const ShortcutsPanel = {
e.preventDefault();
e.stopPropagation();
this.hide();
document.getElementById('info-tab-btn-shortcuts')?.focus();
// In narrow mode the tab button is hidden, so hide()'s previous-focus
// restore is what returns focus; only grab the tab button when visible.
const tabBtn = document.getElementById('info-tab-btn-shortcuts');
if (tabBtn?.offsetParent) tabBtn.focus();
}
});
},
Expand Down
14 changes: 14 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1460,6 +1460,20 @@ <h2 id="modal-title" data-i18n="about_heading">
<button id="canvasToggleBtn" class="view-toggle-btn" aria-pressed="true">Canvas</button>
<button id="codeToggleBtn" class="view-toggle-btn" aria-pressed="false">Code</button>
</div>
<!-- Narrow-mode home for the flock link: #info-panel (which normally
hosts it) is hidden here, and #bottomBar is hidden on wide screens,
so exactly one copy of the link is ever visible/in the a11y tree. -->
<a
href="https://flockxr.com/"
target="_blank"
rel="noopener noreferrer"
id="bottombar-flocklink"
class="bottombar-flocklink"
aria-label="Visit Flock XR website (opens in new tab)"
title="Visit Flock XR website"
>
<img src="./images/inline-flock-xr.svg" alt="" />
</a>
<div id="switch-desc" class="sr-only">
Toggle between coding view and 3D scene view
</div>
Expand Down
4 changes: 2 additions & 2 deletions main/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export function setupInput() {
const inCodeMode =
inNarrowMode && codePanel && getComputedStyle(codePanel).display !== 'none';
if (inNarrowMode && !inCodeMode) {
['#canvasToggleBtn', '#codeToggleBtn'].forEach((sel) =>
['#canvasToggleBtn', '#codeToggleBtn', '#bottombar-flocklink'].forEach((sel) =>
pushUnique(document.querySelector(sel))
);
}
Expand Down Expand Up @@ -187,7 +187,7 @@ export function setupInput() {

// View toggle in code mode — right after zoomInBtn
if (inCodeMode) {
['#canvasToggleBtn', '#codeToggleBtn'].forEach((sel) =>
['#canvasToggleBtn', '#codeToggleBtn', '#bottombar-flocklink'].forEach((sel) =>
pushUnique(document.querySelector(sel))
);
}
Expand Down
14 changes: 13 additions & 1 deletion main/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,16 @@ function resizeCanvas() {
areaHeight -= 60; //Gizmos visible
}

// The bottom bar is position:fixed and #canvasArea is sized to the viewport,
// so the bar overlaps canvasArea's lower edge rather than pushing it up. On
// short viewports that overlap eats into the gizmo strip, hiding the buttons.
// Subtract however much the bar intrudes so the canvas + gizmos sit above it.
const bottomBar = document.getElementById('bottomBar');
if (bottomBar && getComputedStyle(bottomBar).display !== 'none') {
const overlap = areaRect.bottom - bottomBar.getBoundingClientRect().top;
if (overlap > 0) areaHeight = Math.max(1, areaHeight - Math.round(overlap));
}

const aspectRatio = 16 / 9;

let newWidth, newHeight;
Expand Down Expand Up @@ -594,7 +604,9 @@ export function toggleDesignMode() {
switchView('both');
flock.scene.debugLayer.hide();
flockLink.style.display = 'block';
infoPanel.style.display = 'flex';
// Defer to the stylesheet (flex on wide, hidden in narrow mode) rather than
// forcing inline flex, which would re-show the panel behind the pill bar.
infoPanel.style.display = '';
} else {
blocklyArea.style.display = 'none';
codeMode = 'none';
Expand Down
77 changes: 73 additions & 4 deletions style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1001,8 +1001,42 @@ button {
height: 100% !important;
}

#info-panel-tabs {
margin-bottom: calc(35px + max(0px, env(safe-area-inset-bottom, 0px)));
/* In narrow mode the info panel has no room to dock its shortcuts panel, so
it's hidden entirely: 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,
which is declared later in the file and would otherwise win the cascade. */
aside#info-panel {
display: none;
}

.bottombar-flocklink {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
padding: 6px 8px;
}

.bottombar-flocklink img {
height: 18px;
display: block;
/* The logo SVG is purple, which disappears on the purple bar — recolour
the rendered image to white (brightness(0) → black, invert → white).
Scoped to this copy only; the info-panel logo stays purple. */
filter: brightness(0) invert(1);
}

/* <a> isn't covered by the global button/input focus rule, so it'd fall back
to the browser default (reads white on the bar) — match the app's yellow. */
.bottombar-flocklink:focus,
.bottombar-flocklink:focus-visible {
outline: 3px solid var(--color-focus);
outline-offset: 2px;
border-radius: 4px;
}

#blocklyDiv {
Expand All @@ -1021,8 +1055,11 @@ button {
}

@media only screen and (orientation: portrait) and (max-width: 1024px) {
#info-panel {
display: flex;
/* Info panel stays hidden in narrow mode (shortcuts open as a modal); this
rule previously forced it visible in portrait, which would override the
narrow-mode hide above. `aside#info-panel` for the same specificity reason. */
aside#info-panel {
display: none;
}

.resizer {
Expand Down Expand Up @@ -2011,6 +2048,38 @@ kbd {
outline-offset: -2px;
}

/* Shortcuts presented as a modal in narrow layouts, where the info panel has
no room to dock it. The .shortcuts-modal class is added by ShortcutsPanel
only in narrow mode, and the panel is reparented to <body> so it escapes the
info panel's clipping. */
.shortcuts-modal-backdrop {
position: fixed;
inset: 0;
background: var(--color-bg-overlay);
z-index: 1100;
}

.info-tab-panel.shortcuts-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(600px, calc(100vw - 24px));
max-height: 85vh;
overflow-y: auto;
z-index: 1101;
background: var(--color-bg);
color: var(--color-text-primary);
border-radius: 12px;
box-shadow: 0 8px 32px var(--color-shadow-medium);
box-sizing: border-box;
}

.shortcuts-modal-close {
font-weight: bold;
margin-left: 12px;
}

/* Shortcuts tab content */
.shortcuts-panel-header {
display: flex;
Expand Down
Loading