Add a header-icons collapse toggle beside the board title (#6680 follow-up).

A single button, right after the board title (inside the same .home-icon
wrapper), that hides every icon from the mobile/desktop toggle through the
notification bell: mobile/desktop mode, drag-handles toggle, the three
board-wide resize-lock icons, the starred-boards group, create-board, the
board/all-boards header buttons, the view menu, the Admin Panel tabs, and
notifications.

Purely a per-viewer display preference, not persisted anywhere - a plain
Session var (wekan-header-icons-collapsed), the same shape as mobileMode()
right beside it in header.js, not a board setting like the resize locks.

Every icon in that range gets a shared .js-header-collapsible-icon marker
class; the four `+templateName` inclusions and +notifications cannot carry
a class of their own, so each is wrapped in a `span.js-header-collapsible-
icon` that stays `display: contents` outside the collapsed state, so
introducing the wrapper does not change how those icons behave as flex
items of #header-quick-access when nothing is collapsed.
`#header-quick-access.header-icons-collapsed .js-header-collapsible-icon`
is the one CSS rule that actually hides them.

tests/headerIconsCollapse.test.cjs pins the button's position, the
Session-var (not board-setting) shape, that every icon in the range is
marked and nothing outside it is, and both CSS rules.

Thanks to xet7.
This commit is contained in:
Lauri Ojansivu committed 2026-09-09 02:35:14 +03:00
1 parent c481ce1737
commit d1b01924e1
5 files changed
+198 -14

No files matched your search

+18
View File
@@ -477,6 +477,24 @@ body.mobile-mode #header-quick-access .allBoards {
#header-quick-access .js-toggle-same-width-for-all-lists .fa {
font-size: 0.7em;
}
/* #6680 follow-up: collapse every icon from the mobile/desktop toggle
through the notification bell into one .js-toggle-header-icons-collapsed
button beside the title. Some marked elements are pre-existing div
wrappers with their own box styling (.mobile-mode-toggle,
.header-star-group) and keep their normal display outside the collapsed
state; the plain `span.js-header-collapsible-icon` wrappers added around
+boardHeaderButtons/+allBoardsHeaderButtons/+boardViewMenu/
+allBoardsViewMenu/+adminPanelTabs/+notifications did not exist before
this feature and must stay `display: contents` so introducing them does
not change how their own children behave as flex items of
#header-quick-access when NOT collapsed. */
#header-quick-access .header-quick-access-end > span.js-header-collapsible-icon {
display: contents;
}
#header-quick-access.header-icons-collapsed .js-header-collapsible-icon {
display: none !important;
}
#header-quick-access #header-user-bar,
#header-quick-access #header-new-board-icon,
#header-quick-access ul li {
+35 -14
View File
@@ -8,7 +8,7 @@ template(name="header")
//- nodragscroll hands dragging on this bar to client/lib/headerDragscroll.js,
//- which forwards it to whatever actually scrolls. Without it the page-level
//- dragscroll on the body element would scroll the page at the same time.
#header-quick-access.nodragscroll(class="{{themeColorClass}}" role="navigation" aria-label="{{_ 'all-boards'}}")
#header-quick-access.nodragscroll(class="{{themeColorClass}} {{#if headerIconsCollapsed}}header-icons-collapsed{{/if}}" role="navigation" aria-label="{{_ 'all-boards'}}")
//- Home icon - always at left side of logo. The house is the link to All
Boards; the TEXT beside it is the page you are on, which on a board is
the board's title. It used to say "All Boards" on every page, so the one
@@ -40,6 +40,18 @@ template(name="header")
else
= headerTitleText
//- Collapse every icon from the mobile/desktop toggle through the
notification bell (everything given the .js-header-collapsible-icon
class below), right beside the title they clutter up next to. A
purely visual, per-viewer preference - not persisted server-side,
the same way mobileMode() itself is read - so it is a plain
Session var, read/written in header.js exactly like mobileMode.
a.board-header-btn.js-toggle-header-icons-collapsed(title="{{_ 'toggle-header-icons-collapsed'}}")
if headerIconsCollapsed
i.fa.fa-angle-double-right
else
i.fa.fa-angle-double-left
// Logo - visible; on mobile constrained by CSS
unless currentSetting.hideLogo
if currentSetting.customTopLeftCornerLogoImageUrl
@@ -56,7 +68,10 @@ template(name="header")
img(src="{{pathFor '/logo-header.png'}}" alt="{{headerLogoAlt}}" title="{{headerLogoAlt}}")
// Mobile/desktop mode toggle (swapped with zoom)
.mobile-mode-toggle
// #6680 follow-up: .js-header-collapsible-icon marks every icon the new
// .js-toggle-header-icons-collapsed button (by the title) hides as a
// group - see header.css. This is the FIRST one of that range.
.mobile-mode-toggle.js-header-collapsible-icon
a.board-header-btn.js-mobile-mode-toggle(title="{{_ 'mobile-desktop-toggle'}}" class="{{#if mobileMode}}mobile-active{{else}}desktop-active{{/if}}")
if mobileMode
i.mobile-icon
@@ -66,7 +81,7 @@ template(name="header")
i.fa.fa-desktop
// Drag handles toggle - between zoom and mobile mode toggle
a.board-header-btn.js-toggle-desktop-drag-handles(title="{{_ 'show-desktop-drag-handles'}}")
a.board-header-btn.js-toggle-desktop-drag-handles.js-header-collapsible-icon(title="{{_ 'show-desktop-drag-handles'}}")
i.fa.fa-arrows
if isShowDesktopDragHandles
i.fa.fa-check
@@ -80,7 +95,7 @@ template(name="header")
// then swimlane height (up-down arrow).
if isBoardPage
if canLockBoardResize
a.board-header-btn.js-toggle-list-width-resize-lock(title="{{_ 'lock-list-width-resize'}}")
a.board-header-btn.js-toggle-list-width-resize-lock.js-header-collapsible-icon(title="{{_ 'lock-list-width-resize'}}")
i.fa.fa-arrows-h
if isListWidthResizeLocked
i.fa.fa-ban
@@ -89,13 +104,13 @@ template(name="header")
// #6680: board-wide "same width for all lists" - the same mode the
// per-list "Set width" popup already offers per user, exposed here
// as one board setting that applies to every viewer.
a.board-header-btn.js-toggle-same-width-for-all-lists(title="{{_ 'same-width-for-all-lists'}}")
a.board-header-btn.js-toggle-same-width-for-all-lists.js-header-collapsible-icon(title="{{_ 'same-width-for-all-lists'}}")
i.fa.fa-columns
if isSameWidthForAllLists
i.fa.fa-check
else
i.fa.fa-ban
a.board-header-btn.js-toggle-swimlane-height-resize-lock(title="{{_ 'lock-swimlane-height-resize'}}")
a.board-header-btn.js-toggle-swimlane-height-resize-lock.js-header-collapsible-icon(title="{{_ 'lock-swimlane-height-resize'}}")
i.fa.fa-arrows-v
if isSwimlaneHeightResizeLocked
i.fa.fa-ban
@@ -125,7 +140,7 @@ template(name="header")
every pop-over; nothing here has to ask for it.
//- One GROUP: the two adjacent buttons are about the same thing - how
many items you have starred, and whether this one is among them.
.header-star-group
.header-star-group.js-header-collapsible-icon
a.board-header-btn.js-open-starred-boards(title="{{_ 'allboards.starred'}}")
i.fa.fa-caret-down
span.board-star-counter {{starredBoardsCount}}
@@ -152,7 +167,7 @@ template(name="header")
//- The top-bar Create Board control shares the same form and board-template
picker as Add Board on the overview. #3070
a#header-new-board-icon.board-header-btn.js-create-board(title="{{_ 'add-board'}}")
a#header-new-board-icon.board-header-btn.js-create-board.js-header-collapsible-icon(title="{{_ 'add-board'}}")
i.fa.fa-plus
//- The "100%" zoom control that used to sit here is REMOVED. It scaled the
@@ -164,13 +179,15 @@ template(name="header")
//- Everything the board's second header bar held, as icons, left of the
bell. client/components/boards/boardHeader.jade
if isBoardPage
+boardHeaderButtons
span.js-header-collapsible-icon
+boardHeaderButtons
//- All Boards' four controls - Sort, Search, Multi-Selection, Boards in
Archive - as icons, left of the bell, exactly where a board's own
controls are. client/components/boards/boardsList.jade
if isAllBoardsPage
+allBoardsHeaderButtons
span.js-header-collapsible-icon
+allBoardsHeaderButtons
//- The view menu of whatever page has one - Swimlanes / Lists / Calendar
/ Gantt / Table / Stats on a board, Lists / Table on All Boards. A view
@@ -178,18 +195,22 @@ template(name="header")
in the bar that is always on screen, not in a second bar or behind a
panel. docs/Features/Page/Header.md
if isBoardPage
+boardViewMenu
span.js-header-collapsible-icon
+boardViewMenu
else if isAllBoardsPage
+allBoardsViewMenu
span.js-header-collapsible-icon
+allBoardsViewMenu
//- The Admin Panel's four tabs, to the LEFT of the bell. Drawn only in
the Admin Panel: they are navigation between its four pages.
client/components/settings/settingHeader.jade
if isAdminPanel
+adminPanelTabs
span.js-header-collapsible-icon
+adminPanelTabs
// Notifications
+notifications
span.js-header-collapsible-icon
+notifications
//- Everything that belongs to YOU rather than to the page - the divider
that separates the two, help, your account, and the sidebar toggle
+14
View File
@@ -364,6 +364,14 @@ Template.header.helpers({
}
return Utils.getMobileMode();
},
// #6680 follow-up: whether every .js-header-collapsible-icon (mobile/
// desktop toggle through the notification bell) is hidden. A plain,
// per-viewer, per-session preference - not persisted - the same shape as
// mobileMode() above, not a board setting like the resize locks are.
headerIconsCollapsed() {
return !!Session.get('wekan-header-icons-collapsed');
},
});
Template.header.events({
@@ -469,6 +477,12 @@ Template.header.events({
if (!board) return;
board.setSwimlaneHeightResizeLocked(!board.getSwimlaneHeightResizeLocked());
},
// #6680 follow-up: collapse every icon from the mobile/desktop toggle
// through the notification bell, right beside the board title. Purely
// visual and per-session, like mobileMode() - see headerIconsCollapsed().
'click .js-toggle-header-icons-collapsed'() {
Session.set('wekan-header-icons-collapsed', !Session.get('wekan-header-icons-collapsed'));
},
// #6680: the board-wide "same width for all lists" toggle. Enabling it is
// a board setting (admin-only, like the two lock toggles); it does not
// touch each member's own personal fixed-width choice, it just overrides
+1
View File
@@ -1370,6 +1370,7 @@
"lock-list-width-resize": "Lock/unlock resizing list width between lists",
"lock-swimlane-height-resize": "Lock/unlock resizing swimlane height",
"same-width-for-all-lists": "Same width for all lists (whole board)",
"toggle-header-icons-collapsed": "Collapse/expand header icons (mobile/desktop toggle to notifications)",
"drag-to-resize-sidebar": "Drag to resize sidebar",
"drag-to-resize-left-menu": "Drag to resize left menu",
"submit-on-enter": "Submit editors with Enter",
+130
View File
@@ -0,0 +1,130 @@
'use strict';
// #6680 follow-up: a collapse button beside the board title hides every icon
// from the mobile/desktop toggle through the notification bell, so a viewer
// who does not need them can get the whole run out of the way at once.
//
// This is a static wiring test (no Meteor runtime here), pinning:
// - the toggle button sits right after the board title, inside the same
// .home-icon wrapper, with an icon that swaps by state;
// - headerIconsCollapsed()/the click handler use a plain per-session
// Session var, the same shape as mobileMode() beside it - not a board
// setting, since this is a personal viewing preference, not something
// one viewer should be able to hide for everyone else;
// - every icon in the described range carries .js-header-collapsible-icon,
// and nothing outside that range does;
// - the CSS actually hides marked icons when collapsed, and the NEW
// wrapper spans stay display:contents outside that state so introducing
// them does not change any existing icon's flex-item behavior.
//
// Run: node tests/headerIconsCollapse.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
console.log('headerIconsCollapse:');
const jade = read('client/components/main/header.jade');
const js = read('client/components/main/header.js');
const css = read('client/components/main/header.css');
test('the toggle button sits right after the board title, inside .home-icon', () => {
const titleAt = jade.indexOf('span.header-page-title');
const toggleAt = jade.indexOf('a.board-header-btn.js-toggle-header-icons-collapsed');
assert.ok(titleAt !== -1 && toggleAt !== -1, 'both elements exist');
assert.ok(toggleAt > titleAt, 'the toggle comes after the title');
// Both are 8-space-indented children of "span.home-icon.allBoards" (6
// spaces): the toggle line itself must be indented exactly like the title.
const titleIndent = jade.slice(0, titleAt).match(/( *)$/)[1].length;
const toggleIndent = jade.slice(0, toggleAt).match(/( *)$/)[1].length;
assert.strictEqual(toggleIndent, titleIndent,
'the toggle is a sibling of the title, not nested somewhere else');
// And no OTHER top-level element (e.g. "// Logo", a comment at the same
// indent as span.home-icon itself) sits between them.
const homeIconIndent = titleIndent - 2;
const betweenLines = jade.slice(titleAt, toggleAt).split('\n').slice(1, -1);
for (const l of betweenLines) {
if (!l.trim()) continue;
const indent = l.match(/^ */)[0].length;
assert.ok(indent > homeIconIndent, `line inside .home-icon must be indented further: "${l}"`);
}
});
test('the icon swaps by headerIconsCollapsed, and the tooltip is translated', () => {
const block = jade.slice(jade.indexOf('js-toggle-header-icons-collapsed'),
jade.indexOf('js-toggle-header-icons-collapsed') + 300);
assert.ok(/title="\{\{_ 'toggle-header-icons-collapsed'\}\}"/.test(block));
assert.ok(/if headerIconsCollapsed/.test(block));
assert.ok(block.includes('fa-angle-double-right') && block.includes('fa-angle-double-left'),
'both icon states are drawn');
});
test('headerIconsCollapsed() and its click handler use a plain Session var, like mobileMode()', () => {
assert.ok(/headerIconsCollapsed\(\) \{\s*return !!Session\.get\('wekan-header-icons-collapsed'\);/
.test(js.replace(/\n\s*/g, ' ')), 'the helper reads the Session var');
const at = js.indexOf("'click .js-toggle-header-icons-collapsed'");
assert.ok(at !== -1, 'the click handler exists');
const body = js.slice(at, js.indexOf('},', at));
assert.ok(body.includes("Session.set('wekan-header-icons-collapsed'"),
'the handler writes the same Session var');
// Negative: this must NOT be a board setting (no Boards.updateAsync/
// board.setXyz call) - it is a personal viewing preference.
assert.ok(!/board\.set|Boards\.updateAsync/.test(body),
'the handler never persists to the board (negative)');
});
test('every icon in the mobile-toggle-to-notifications range carries the marker class', () => {
for (const needle of [
'.mobile-mode-toggle.js-header-collapsible-icon',
'js-toggle-desktop-drag-handles.js-header-collapsible-icon',
'js-toggle-list-width-resize-lock.js-header-collapsible-icon',
'js-toggle-same-width-for-all-lists.js-header-collapsible-icon',
'js-toggle-swimlane-height-resize-lock.js-header-collapsible-icon',
'.header-star-group.js-header-collapsible-icon',
'js-create-board.js-header-collapsible-icon',
]) {
assert.ok(jade.includes(needle), `expected to find: ${needle}`);
}
// The four template inclusions and notifications are wrapped, since a
// Spacebars `+templateName` inclusion cannot carry a class of its own.
for (const included of ['+boardHeaderButtons', '+allBoardsHeaderButtons',
'+boardViewMenu', '+allBoardsViewMenu', '+adminPanelTabs', '+notifications']) {
const at = jade.indexOf(included);
assert.ok(at !== -1, `${included} is still included`);
const before = jade.slice(Math.max(0, at - 60), at);
assert.ok(/span\.js-header-collapsible-icon\s*\n\s*$/.test(before),
`${included} is wrapped in span.js-header-collapsible-icon`);
}
});
test('nothing outside that range carries the marker class (negative)', () => {
for (const outside of ['home-icon', 'header-logo', 'header-account-group',
'header-notify-icon', 'js-open-notifications']) {
// None of these element definitions should have the collapsible class
// directly on them - they are either before the range (home/logo) or
// after it (the account group and its contents).
const re = new RegExp(`${outside}[^\\n]*js-header-collapsible-icon`);
assert.ok(!re.test(jade), `${outside} must not carry the collapsible class`);
}
});
test('the CSS hides every marked icon when collapsed, and the new wrapper spans stay transparent otherwise', () => {
assert.ok(/#header-quick-access\.header-icons-collapsed \.js-header-collapsible-icon \{[^}]*display:\s*none\s*!important/
.test(css.replace(/\s+/g, ' ')), 'the collapsed rule hides marked icons');
assert.ok(/#header-quick-access \.header-quick-access-end > span\.js-header-collapsible-icon \{[^}]*display:\s*contents/
.test(css.replace(/\s+/g, ' ')), 'the new span wrappers default to display: contents');
});
test('the root element carries the collapsed class reactively', () => {
assert.ok(/#header-quick-access\.nodragscroll\(class="\{\{themeColorClass\}\} \{\{#if headerIconsCollapsed\}\}header-icons-collapsed\{\{\/if\}\}"/
.test(jade), 'the class is bound to the same helper the button reads');
});
console.log(`\nheaderIconsCollapse: ${passed} tests passed`);