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
27 changes: 19 additions & 8 deletions accessibility/keyboardui.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ 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;
// 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.
const isNarrowLayout = () =>
window.matchMedia('(max-width: 1024px) and (orientation: landscape)').matches;

// Area menu accessed with Ctrl + B to quickly skip to
// different areas on the interface
Expand Down Expand Up @@ -38,11 +41,13 @@ const AreaManager = {
get effectiveAreas() {
const reloadBtn = document.getElementById('reload-btn');
const reloadConnected = reloadBtn?.isConnected;
const narrow = isNarrowLayout();
const infoPanelTabs = document.getElementById('info-panel-tabs');
const infoPanelTabsHidden = !infoPanelTabs || infoPanelTabs.offsetWidth === 0;
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') {
// #info-panel-tabs is a dead target whenever it's not actually on screen
// (hidden by the landscape-narrow CSS, or display:none via canvasArea in
// narrow Code view) — hand area 5 to the pill toggle instead.
if (infoPanelTabsHidden && a.label === '5') {
return {
selector: '#viewToggle',
label: '5',
Expand Down Expand Up @@ -724,7 +729,13 @@ const ShortcutsPanel = {
window.addEventListener('resize', () => {
if (this.panel.classList.contains('hidden')) return;
if (isNarrowLayout() && !this._modalActive) this.enterModal();
else if (!isNarrowLayout() && this._modalActive) this.exitModal();
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.
this.exitModal();
this.previousFocus?.focus();
}
});
},

Expand Down
5 changes: 3 additions & 2 deletions main/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,8 @@ function switchView(view) {
blocklyArea.style.display = 'block';
canvasArea.style.display = '';
flockLink.style.display = 'block';
if (resizer) resizer.style.display = 'block';
// Defer to the stylesheet: block on wide screens, hidden at <=1024px
if (resizer) resizer.style.display = '';
blocklyArea.style.width = '0';
canvasArea.style.width = '0';
blocklyArea.style.flex = '2 1 0'; // 2/3 of the space
Expand Down Expand Up @@ -555,7 +556,7 @@ export function togglePlayMode() {
bottomBar.style.display = '';
flockLink.style.display = 'block';
if (infoPanel) infoPanel.style.display = '';
if (resizer) resizer.style.display = 'block';
if (resizer) resizer.style.display = '';
if (savedShortcutsVisible) {
window.flockShortcutsPanel?.show();
savedShortcutsVisible = false;
Expand Down
77 changes: 43 additions & 34 deletions style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1007,12 +1007,48 @@ button {
height: 100% !important;
}

/* 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. */
/* Reserves room above the fixed #bottomBar for whichever docks here — the
shortcuts tabs in narrow *portrait* (where the info panel is still docked,
see the landscape-only hide below), or otherwise just keeping tabs clear
of the bar. Without this the tabs/pill-toggle area render underneath the
fixed bar and get covered by it. */
#info-panel-tabs {
margin-bottom: calc(35px + max(0px, env(safe-area-inset-bottom, 0px)));
}

#blocklyDiv {
height: calc(100% - 85px - max(0px, env(safe-area-inset-bottom, 0px))) !important;
}
}

/* Hidden by default; shown only in the narrow+landscape case below. Kept as a
base rule (rather than solely relying on #bottomBar being display:none on
wide screens) so it doesn't render unstyled in narrow *portrait* layouts,
where the info panel still has room to host the docked copy instead. */
.bottombar-flocklink {
display: none;
}

/* Narrow layouts (either orientation) have no side-by-side panels to resize,
so the drag handle is hidden — also keeps its tabindex out of the tab
order and drops it from the Ctrl+B highlight areas. */
@media only screen and (max-width: 1024px) {
.resizer {
display: none;
}
}

/* 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. */
@media (max-width: 1024px) and (orientation: landscape) {
aside#info-panel {
display: none;
}
Expand All @@ -1028,7 +1064,7 @@ button {
}

.bottombar-flocklink img {
height: 18px;
height: 15px;
display: block;
/* The logo SVG is purple, which disappears on the purple bar — recolour
the rendered image to white (brightness(0) → black, invert → white).
Expand All @@ -1044,33 +1080,6 @@ button {
outline-offset: 2px;
border-radius: 4px;
}

#blocklyDiv {
height: calc(100% - 85px - max(0px, env(safe-area-inset-bottom, 0px))) !important;
}
}

@media only screen and (orientation: landscape) and (max-width: 768px) {
#info-panel {
display: none;
}

.resizer {
display: none;
}
}

@media only screen and (orientation: portrait) and (max-width: 1024px) {
/* 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 {
display: none;
}
}

@media (min-width: 1025px) {
Expand Down
84 changes: 66 additions & 18 deletions tests/keyboardui.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -218,26 +218,30 @@ export function runKeyboardUiTests(flock) {
});

describe('effectiveAreas', function () {
it('swaps area 5 for the view-toggle pill in a narrow layout', function () {
const saved = window.matchMedia;
window.matchMedia = () => ({ matches: true });
try {
const area5 = AreaManager.effectiveAreas.find((a) => a.label === '5');
expect(area5.selector).to.equal('#viewToggle');
} finally {
window.matchMedia = saved;
}
afterEach(function () {
document.getElementById('info-panel-tabs')?.remove();
});

it('keeps area 5 as the info panel tabs in a wide layout', function () {
const saved = window.matchMedia;
window.matchMedia = () => ({ matches: false });
try {
const area5 = AreaManager.effectiveAreas.find((a) => a.label === '5');
expect(area5.selector).to.equal('#info-panel-tabs');
} finally {
window.matchMedia = saved;
}
it('swaps area 5 for the view-toggle pill when #info-panel-tabs is not on screen', function () {
const el = document.createElement('div');
el.id = 'info-panel-tabs';
el.style.display = 'none';
document.body.appendChild(el);
const area5 = AreaManager.effectiveAreas.find((a) => a.label === '5');
expect(area5.selector).to.equal('#viewToggle');
});

it('swaps area 5 for the view-toggle pill when #info-panel-tabs does not exist', function () {
const area5 = AreaManager.effectiveAreas.find((a) => a.label === '5');
expect(area5.selector).to.equal('#viewToggle');
});

it('keeps area 5 as the info panel tabs when #info-panel-tabs is on screen', function () {
const el = document.createElement('div');
el.id = 'info-panel-tabs';
document.body.appendChild(el);
const area5 = AreaManager.effectiveAreas.find((a) => a.label === '5');
expect(area5.selector).to.equal('#info-panel-tabs');
});

it('swaps area 9 for the reload button when one is connected to the DOM', function () {
Expand Down Expand Up @@ -464,6 +468,50 @@ export function runKeyboardUiTests(flock) {
});
});

describe('resize-triggered modal switching', function () {
afterEach(function () {
ShortcutsPanel.exitModal();
ShortcutsPanel.hide();
});

it('enters modal mode on resize when the layout becomes narrow+landscape', function () {
ShortcutsPanel.show();
expect(ShortcutsPanel._modalActive).to.not.equal(true);
const saved = window.matchMedia;
window.matchMedia = () => ({ matches: true });
try {
window.dispatchEvent(new Event('resize'));
expect(ShortcutsPanel._modalActive).to.equal(true);
} finally {
window.matchMedia = saved;
}
});

it('exits modal mode and restores focus on resize when the layout leaves narrow+landscape', 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 closeBtn = ShortcutsPanel.panel.querySelector('.shortcuts-modal-close');
closeBtn.focus();
expect(document.activeElement).to.equal(closeBtn);

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(ShortcutsPanel.previousFocus);
});
});

describe('trapFocus', function () {
afterEach(function () {
ShortcutsPanel.exitModal();
Expand Down
Loading