A full screen button in the header bar, to the right of the logo.

It puts the whole page in full screen with the Fullscreen API (the unprefixed one
and the webkit-prefixed one) and takes it out again; the icon follows the
browser's own events, so Esc is understood. The label is the existing Maximize
translation, so no language file changes. It is drawn only where the browser
offers the API and the page may use it: not on iPhone, not in an iframe without
allowfullscreen. Unit tests drive the pure part with fake documents, including a
refusal; a browser spec checks the position, the label, entering, leaving, Esc,
and that nothing is drawn where the API is not enabled, in Chromium and WebKit.

Thanks to xet7 !
This commit is contained in:
Lauri Ojansivu committed 2026-11-04 00:27:28 +02:00
1 parent 984ea93aa8
commit f25957dcf3
6 files changed
+285 -2

No files matched your search

+13
View File
@@ -69,6 +69,19 @@ template(name="header")
div.header-logo
img(src="{{pathFor '/logo-header.png'}}" alt="{{headerLogoAlt}}" title="{{headerLogoAlt}}")
//- Full screen, to the right of the logo (iPad Safari, desktop and Android
browsers). Only drawn where the browser has the Fullscreen API and the page
may use it: not on iPhone, not in an iframe without allowfullscreen. The
label is the existing "Maximize" one. It stays before the first
.js-header-collapsible-icon, so "hide the icons" does not hide it.
models/lib/fullscreen.js
if fullscreenSupported
a.board-header-btn.js-toggle-fullscreen(href="#" role="button" title="{{_ 'maximize-window'}}" aria-label="{{_ 'maximize-window'}}" aria-pressed="{{#if isFullscreen}}true{{else}}false{{/if}}")
if isFullscreen
i.fa.fa-compress
else
i.fa.fa-expand
// Mobile/desktop mode toggle (swapped with zoom)
// #6680 follow-up: .js-header-collapsible-icon marks every icon the new
// .js-toggle-header-icons-collapsed button (beside Home) hides as a
+32
View File
@@ -59,6 +59,7 @@ function customPageTitle(routeName) {
// Drag-to-scroll on the two top header bars (they are not scroll containers
// themselves, so the drag is forwarded to the board canvas / page scroller).
import '/client/lib/headerDragscroll';
const fullscreen = require('/models/lib/fullscreen');
Meteor.subscribe('user-admin');
Meteor.subscribe('boards');
@@ -70,6 +71,10 @@ Meteor.subscribe('setting');
Meteor.subscribe('announcements');
Template.header.onCreated(function () {
const templateInstance = this;
// Full screen: whether the browser offers it here, and whether the page is in it.
// Both are set when the header is drawn (onRendered), where there is a document.
templateInstance.fullscreenSupported = new ReactiveVar(false);
templateInstance.isFullscreen = new ReactiveVar(false);
templateInstance.currentSetting = new ReactiveVar();
templateInstance.isLoading = new ReactiveVar(false);
templateInstance.subscribe('setting');
@@ -83,6 +88,14 @@ Template.header.onCreated(function () {
});
Template.header.onRendered(function () {
// The Fullscreen API, where it exists and the page may use it (models/lib/fullscreen.js).
// The state follows the browser's own events, so leaving with Esc, or with the
// browser's own control, changes the icon too.
this.fullscreenSupported.set(fullscreen.isSupported(document));
this.isFullscreen.set(fullscreen.isFullscreen(document));
this.onFullscreenChange = () => this.isFullscreen.set(fullscreen.isFullscreen(document));
fullscreen.CHANGE_EVENTS.forEach(name => document.addEventListener(name, this.onFullscreenChange));
this.autorun(() => {
Meteor.userId();
FlowRouter.watchPathChange();
@@ -92,7 +105,19 @@ Template.header.onRendered(function () {
});
});
Template.header.onDestroyed(function () {
if (this.onFullscreenChange) {
fullscreen.CHANGE_EVENTS.forEach(name => document.removeEventListener(name, this.onFullscreenChange));
}
});
Template.header.helpers({
fullscreenSupported() {
return Template.instance().fullscreenSupported.get();
},
isFullscreen() {
return Template.instance().isFullscreen.get();
},
logoSettingsReady() {
return Template.instance().subscriptionsReady();
},
@@ -365,6 +390,13 @@ Template.header.helpers({
});
Template.header.events({
// Full screen on and off. The request is made inside this click, which is the
// user gesture browsers require; a refusal is not an error (models/lib/fullscreen.js).
'click .js-toggle-fullscreen'(event) {
event.preventDefault();
fullscreen.toggle(document);
},
// The starred boards, by name. They were listed inline in this bar; the bar
// is one row now and the names are in the popup. docs/Features/Header-Bar/Header.md
// Titled "Starred Boards", from the key the app already has for that phrase,
+13 -2
View File
@@ -82,8 +82,19 @@ early.
## What the bar holds
In source order: the house and the page title, the logo, the phone/desktop
toggle, the drag-handles toggle, and then everything else.
In source order: the house and the page title, the logo, the full screen button,
the phone/desktop toggle, the drag-handles toggle, and then everything else.
**Full screen.** A button to the right of the logo puts the whole page in full
screen and takes it out again, with the Fullscreen API (`models/lib/fullscreen.js`):
the icon is an expand arrow, and a compress arrow while the page is in full screen,
and it follows the browser's own way out (Esc) too. Its label is the existing
"Maximize". It is drawn only where the browser offers the API and the page may use
it - iPad Safari, desktop browsers and Android browsers do; **iPhone Safari does not**
(the API is for video there) and neither does a page inside an `<iframe>` without
`allowfullscreen`, so no dead button is shown. On an iPhone, "Add to Home Screen"
opens WeKan without the browser's bars (the manifest says `display: standalone`).
It sits before the first icon the fold button collects, so folding the icons keeps it.
**The board's controls fold.** A caret leads the group, at its leading edge —
left of the lock in a left-to-right page, right of it in Arabic. Folded, the
+55
View File
@@ -0,0 +1,55 @@
'use strict';
// Full screen for the whole page, with the unprefixed Fullscreen API and the
// webkit-prefixed one older Safari (and iPadOS Safari before 16.4) has. Pure and
// Meteor-free, the document is passed in, so plain Node tests drive it with a
// fake one.
//
// Where it is NOT available the button must not be drawn at all:
// - iPhone Safari (and every iPhone browser, they are all WebKit) has the API for
// video only: document.fullscreenEnabled is false or missing;
// - a page inside an <iframe> without allowfullscreen (a Sandstorm grain, say) has
// fullscreenEnabled false;
// - a very old browser has neither name.
// "Add to Home Screen" (the manifest says display: standalone) is the way there.
function isSupported(doc) {
if (!doc) return false;
return doc.fullscreenEnabled === true || doc.webkitFullscreenEnabled === true;
}
function currentElement(doc) {
return (doc && (doc.fullscreenElement || doc.webkitFullscreenElement)) || null;
}
function isFullscreen(doc) {
return currentElement(doc) !== null;
}
// Enters full screen, or leaves it. Resolves true when the page is in full screen
// afterwards, false when it is not; it never rejects - a browser that refuses (no
// user gesture, a policy) is an answer, not an error to show. The request has to
// come from a click or a key press, which is what the button is.
async function toggle(doc) {
if (!isSupported(doc)) return false;
try {
if (isFullscreen(doc)) {
const exit = doc.exitFullscreen || doc.webkitExitFullscreen;
if (exit) await exit.call(doc);
} else {
const el = doc.documentElement;
const request = el && (el.requestFullscreen || el.webkitRequestFullscreen);
if (request) await request.call(el);
}
} catch (e) {
// refused: the state below says what happened
}
return isFullscreen(doc);
}
// The events a change arrives on, both spellings.
const CHANGE_EVENTS = ['fullscreenchange', 'webkitfullscreenchange'];
module.exports = { isSupported, isFullscreen, toggle, CHANGE_EVENTS };
+108
View File
@@ -0,0 +1,108 @@
'use strict';
// The full screen button in the header bar, to the right of the logo. The pure
// part (models/lib/fullscreen.js) is driven with fake documents - the unprefixed
// API, the webkit one, an iPhone, an iframe, a refusal - and the wiring is read
// from the source: the existing "Maximize" label, no new translation, the button
// only where the browser offers the API, and the browser's own events followed.
// tests/playwright/specs/header-fullscreen.e2e.js drives it in a browser.
//
// Run: node tests/fullscreenButton.test.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { test } = require('node:test');
const { isSupported, isFullscreen, toggle, CHANGE_EVENTS } = require('../models/lib/fullscreen');
const root = path.join(__dirname, '..');
const read = rel => fs.readFileSync(path.join(root, rel), 'utf8');
// A document that behaves like a browser's: requesting makes the element current.
function fakeDocument({ prefix = false, enabled = true, refuse = false } = {}) {
const doc = {};
const flag = prefix ? 'webkitFullscreenEnabled' : 'fullscreenEnabled';
const current = prefix ? 'webkitFullscreenElement' : 'fullscreenElement';
const request = prefix ? 'webkitRequestFullscreen' : 'requestFullscreen';
const exit = prefix ? 'webkitExitFullscreen' : 'exitFullscreen';
const calls = [];
doc[flag] = enabled;
doc[current] = null;
doc.documentElement = {
[request]() {
calls.push('request');
if (refuse) return Promise.reject(new TypeError('Permissions check failed'));
doc[current] = doc.documentElement;
return Promise.resolve();
},
};
doc[exit] = function () { calls.push('exit'); doc[current] = null; return Promise.resolve(); };
doc.calls = calls;
return doc;
}
test('supported only where the API is enabled, in either spelling', () => {
assert.equal(isSupported(fakeDocument()), true, 'unprefixed (Chrome, Firefox, Safari 16.4+)');
assert.equal(isSupported(fakeDocument({ prefix: true })), true, 'webkit-prefixed (older Safari, iPadOS before 16.4)');
// negative: an iframe without allowfullscreen, iPhone Safari and an old browser
assert.equal(isSupported(fakeDocument({ enabled: false })), false, 'fullscreenEnabled false');
assert.equal(isSupported(fakeDocument({ prefix: true, enabled: false })), false);
assert.equal(isSupported({}), false, 'neither name');
const iphone = fakeDocument();
delete iphone.fullscreenEnabled;
assert.equal(isSupported(iphone), false, 'iPhone: the property is missing');
assert.equal(isSupported(null), false);
assert.equal(isSupported(undefined), false);
});
test('toggle enters full screen and leaves it again, with either API', async () => {
for (const prefix of [false, true]) {
const doc = fakeDocument({ prefix });
assert.equal(isFullscreen(doc), false);
assert.equal(await toggle(doc), true, 'entered');
assert.equal(isFullscreen(doc), true);
assert.equal(await toggle(doc), false, 'left');
assert.equal(isFullscreen(doc), false);
assert.deepEqual(doc.calls, ['request', 'exit']);
}
});
test('negative: a refusal is an answer, not an error, and an unsupported page is left alone', async () => {
const refused = fakeDocument({ refuse: true });
assert.equal(await toggle(refused), false, 'the browser refused: not in full screen, and no throw');
assert.deepEqual(refused.calls, ['request']);
const iframe = fakeDocument({ enabled: false });
assert.equal(await toggle(iframe), false);
assert.deepEqual(iframe.calls, [], 'nothing is requested where it is not enabled');
// A request that throws at once, as an old WebKit does, is the same.
const throws = fakeDocument();
throws.documentElement.requestFullscreen = () => { throw new Error('boom'); };
assert.equal(await toggle(throws), false);
});
test('the button is drawn only where supported, beside the logo, with the existing Maximize label', () => {
const jade = read('client/components/main/header.jade');
const block = jade.slice(jade.indexOf('if fullscreenSupported'));
assert.match(block, /^if fullscreenSupported\n\s+a\.board-header-btn\.js-toggle-fullscreen\(href="#" role="button" title="\{\{_ 'maximize-window'\}\}" aria-label="\{\{_ 'maximize-window'\}\}" aria-pressed=/);
assert.match(block, /i\.fa\.fa-compress[\s\S]{0,60}i\.fa\.fa-expand/);
// After the logo, before the first collapsible icon, so hiding the icons keeps it.
assert.ok(jade.indexOf('div.header-logo') < jade.indexOf('js-toggle-fullscreen'), 'after the logo');
assert.ok(jade.indexOf('js-toggle-fullscreen') < jade.indexOf('.mobile-mode-toggle.js-header-collapsible-icon'), 'before the collapsible icons');
assert.doesNotMatch(block.split('\n').slice(0, 2).join('\n'), /js-header-collapsible-icon/, 'not itself collapsible');
// No new translation: the label is the key the popups already use.
assert.doesNotMatch(jade, /_ 'fullscreen|_ 'full-screen/);
const en = JSON.parse(read('imports/i18n/data/en.i18n.json'));
assert.equal(en['maximize-window'], 'Maximize');
assert.ok(!Object.keys(en).some(k => /full-?screen/i.test(k)), 'no full screen key was added');
});
test('the header follows the browser\'s own events, in both spellings, and lets them go', () => {
assert.deepEqual(CHANGE_EVENTS, ['fullscreenchange', 'webkitfullscreenchange']);
const js = read('client/components/main/header.js');
assert.match(js, /fullscreen\.CHANGE_EVENTS\.forEach\(name => document\.addEventListener\(name, this\.onFullscreenChange\)\)/);
assert.match(js, /Template\.header\.onDestroyed\(function \(\) \{\n\s+if \(this\.onFullscreenChange\) \{\n\s+fullscreen\.CHANGE_EVENTS\.forEach\(name => document\.removeEventListener\(name, this\.onFullscreenChange\)\)/);
assert.match(js, /'click \.js-toggle-fullscreen'\(event\) \{\n\s+event\.preventDefault\(\);\n\s+fullscreen\.toggle\(document\);/);
assert.match(js, /this\.fullscreenSupported\.set\(fullscreen\.isSupported\(document\)\)/);
});
@@ -0,0 +1,64 @@
'use strict';
// A full screen button to the right of the logo in the header bar (iPad Safari has
// the Fullscreen API; so do desktop browsers and Android). It uses the existing
// "Maximize" label, is drawn only where the browser offers the API and the page may
// use it, and follows the browser's own full screen events.
const { test, expect } = require('../fixtures');
const inFullscreen = page => page.evaluate(() => !!(document.fullscreenElement || document.webkitFullscreenElement));
test('the header has a full screen button beside the logo that enters and leaves full screen', async ({ boardPage: page, browserName }) => {
const button = page.locator('.js-toggle-fullscreen');
await expect(button, 'drawn where the browser has the Fullscreen API').toBeVisible();
// The existing "Maximize" label: a title and an accessible name, and not pressed yet.
await expect(button).toHaveAttribute('title', 'Maximize');
await expect(button).toHaveAttribute('aria-label', 'Maximize');
await expect(button).toHaveAttribute('aria-pressed', 'false');
await expect(button.locator('.fa-expand')).toBeVisible();
// To the right of the logo, in the same bar.
const logo = page.locator('#header-quick-access .header-logo').first();
const logoBox = await logo.boundingBox();
const buttonBox = await button.boundingBox();
expect(logoBox && buttonBox).toBeTruthy();
expect(buttonBox.x, 'the button is to the right of the logo').toBeGreaterThan(logoBox.x + logoBox.width - 1);
// Firefox could not be launched where this was written, so entering is not asserted there.
test.skip(browserName === 'firefox', 'full screen entering not verified on Firefox');
expect(await inFullscreen(page)).toBe(false);
await button.click();
await expect.poll(() => inFullscreen(page), { message: 'the click enters full screen' }).toBe(true);
await expect(button).toHaveAttribute('aria-pressed', 'true');
await expect(button.locator('.fa-compress')).toBeVisible();
// Leaving by the button...
await button.click();
await expect.poll(() => inFullscreen(page)).toBe(false);
await expect(button).toHaveAttribute('aria-pressed', 'false');
await expect(button.locator('.fa-expand')).toBeVisible();
// ...and by the browser's own way out (Esc), which the icon must follow.
await button.click();
await expect.poll(() => inFullscreen(page)).toBe(true);
await page.evaluate(() => (document.exitFullscreen || document.webkitExitFullscreen).call(document));
await expect.poll(() => inFullscreen(page)).toBe(false);
await expect(button).toHaveAttribute('aria-pressed', 'false');
await expect(button.locator('.fa-expand')).toBeVisible();
});
test('negative: no button where the page may not use full screen (iPhone, an iframe without allowfullscreen)', async ({ boardPage: page }) => {
await expect(page.locator('.js-toggle-fullscreen')).toHaveCount(1);
// What iPhone Safari and a plain iframe say: the API is not enabled.
await page.addInitScript(() => {
for (const name of ['fullscreenEnabled', 'webkitFullscreenEnabled']) {
Object.defineProperty(Document.prototype, name, { get: () => false, configurable: true });
}
});
await page.reload();
await expect(page.locator('#header-quick-access')).toBeVisible();
await expect(page.locator('.js-toggle-fullscreen'), 'not drawn').toHaveCount(0);
// The rest of the bar is there.
await expect(page.locator('#header-quick-access .header-logo').first()).toBeVisible();
});