diff --git a/assets/controllers/common/hide_sidebar_controller.js b/assets/controllers/common/hide_sidebar_controller.js
index c65cdcdf3..1f1d27485 100644
--- a/assets/controllers/common/hide_sidebar_controller.js
+++ b/assets/controllers/common/hide_sidebar_controller.js
@@ -21,14 +21,14 @@ import {Controller} from "@hotwired/stimulus";
const STORAGE_KEY = 'hide_sidebar';
-export default class extends Controller {
- /**
- * The element representing the sidebar which can be hidden.
- * @type {HTMLElement}
- * @private
- */
- _sidebar;
+/**
+ * The class which is set on the root (html) element, when the sidebar is hidden. The actual sliding is done in CSS
+ * (see layout.css). The class is put on the root element, so that it can be applied by an inline script in the
+ * base template before the page is rendered, and is not lost on Turbo navigations.
+ */
+const HIDDEN_CLASS = 'sidebar-hidden';
+export default class extends Controller {
/**
* The element of the container which is expanded to the full width.
* @type {HTMLElement}
@@ -36,60 +36,110 @@ export default class extends Controller {
*/
_container;
- /**
- * The button which toggles the sidebar.
- * @private
- */
- _toggle_button;
-
- _hidden = false;
-
connect() {
- this._sidebar = document.getElementById('fixed-sidebar');
this._container = document.getElementById('main');
- this._toggle_button = this.element;
+
+ this._onTransitionEnd = this._onTransitionEnd.bind(this);
+ this._container?.addEventListener('transitionend', this._onTransitionEnd);
+
+ this._onKeydown = this._onKeydown.bind(this);
+ document.addEventListener('keydown', this._onKeydown);
//Make the state persistent over reloads
- if(localStorage.getItem(STORAGE_KEY) === 'true') {
- this.hideSidebar();
- }
+ this._apply(this._readState());
}
- hideSidebar() {
- this._sidebar.classList.add('d-none');
-
- this._container.classList.remove(...['col-md-9', 'col-lg-10', 'offset-md-3', 'offset-lg-2']);
- this._container.classList.add('col-12');
+ disconnect() {
+ this._container?.removeEventListener('transitionend', this._onTransitionEnd);
+ document.removeEventListener('keydown', this._onKeydown);
+ }
- //Change button icon
- this._toggle_button.innerHTML = '';
+ /**
+ * Pressing "[" anywhere on the page (outside of text fields) toggles the sidebar, like the button does.
+ */
+ _onKeydown(event) {
+ if (event.key !== '[' || event.ctrlKey || event.metaKey || event.altKey || event.defaultPrevented) {
+ return;
+ }
- localStorage.setItem(STORAGE_KEY, 'true');
- this._hidden = true;
- }
+ const active = document.activeElement;
+ if (active && (active.isContentEditable || active.closest('input, textarea, select, [contenteditable]'))) {
+ return;
+ }
- showSidebar() {
- this._sidebar.classList.remove('d-none');
+ //The button is not shown on small screens, where the sidebar is collapsed into the navbar instead
+ //(offsetParent can not be used for this check: it is always null inside the fixed positioned navbar)
+ if (this.element.getClientRects().length === 0) {
+ return;
+ }
- this._container.classList.remove('col-12');
- this._container.classList.add(...['col-md-9', 'col-lg-10', 'offset-md-3', 'offset-lg-2']);
+ event.preventDefault();
+ this.toggleSidebar();
+ }
+ get hidden() {
+ return document.documentElement.classList.contains(HIDDEN_CLASS);
+ }
- //Change button icon
- this._toggle_button.innerHTML = '';
+ hideSidebar() {
+ this._apply(true);
+ this._saveState(true);
+ }
- localStorage.setItem(STORAGE_KEY, 'false');
- this._hidden = false;
+ showSidebar() {
+ this._apply(false);
+ this._saveState(false);
}
toggleSidebar() {
- if(this._hidden) {
+ if (this.hidden) {
this.showSidebar();
} else {
this.hideSidebar();
}
//Hide the tootip on the button
- this._toggle_button.blur();
+ this.element.blur();
+
+ //If nothing is animated (e.g. reduced motion), no transitionend event is fired, so notify directly
+ if (!this._container || parseFloat(getComputedStyle(this._container).transitionDuration) === 0) {
+ this._notifyResize();
+ }
+ }
+
+ _apply(hidden) {
+ document.documentElement.classList.toggle(HIDDEN_CLASS, hidden);
+ this.element.setAttribute('aria-expanded', hidden ? 'false' : 'true');
+ }
+
+ _readState() {
+ try {
+ return localStorage.getItem(STORAGE_KEY) === 'true';
+ } catch (e) {
+ return this.hidden;
+ }
+ }
+
+ _saveState(hidden) {
+ try {
+ localStorage.setItem(STORAGE_KEY, hidden ? 'true' : 'false');
+ } catch (e) {
+ //Storage is not available, the state is just not persisted then
+ }
+ }
+
+ _onTransitionEnd(event) {
+ if (event.target === this._container && event.propertyName === 'width') {
+ this._notifyResize();
+ }
+ }
+
+ /**
+ * The width of the content area has changed, without the window being resized. Tell everybody who sizes itself
+ * according to the available width (like datatables with its columns and fixed header) to recalculate.
+ * @private
+ */
+ _notifyResize() {
+ window.dispatchEvent(new Event('resize'));
}
-}
\ No newline at end of file
+}
diff --git a/assets/css/app/layout.css b/assets/css/app/layout.css
index 588089269..70be9ea43 100644
--- a/assets/css/app/layout.css
+++ b/assets/css/app/layout.css
@@ -128,18 +128,61 @@ body {
}
/*
-The sidebar toggle button floats on the left side and is hidden when the viewport is to small for
-showing the sidebar (on devices with md or higher)
+The sidebar can be slid out of view on devices which show it permanently (md or higher). The state is a class on the
+ element, so it can be applied before the first paint and survives Turbo navigations.
+The toggle button is part of the navbar, so it does not cover any content and stays reachable in both states.
*/
#sidebar-toggle-button {
- position: fixed;
- left: 2px;
- bottom: 50%;
+ align-items: center;
+ justify-content: center;
+ width: 2rem;
+ height: 2rem;
+ padding: 0;
+ margin-right: 0.5rem;
+ border: 0;
+ color: var(--bs-navbar-color);
}
-@media (max-width: 576px) {
- #sidebar-toggle-button {
- display: none;
+#sidebar-toggle-button:hover, #sidebar-toggle-button:focus-visible {
+ color: var(--bs-navbar-hover-color);
+ background-color: var(--bs-secondary-bg);
+}
+
+/* The sidebar pane of the icon is filled while the sidebar is shown, and empty while it is hidden */
+.sidebar-toggle-pane {
+ opacity: 0.5;
+ transition: opacity 0.3s ease;
+}
+
+.sidebar-hidden .sidebar-toggle-pane {
+ opacity: 0;
+}
+
+@media (min-width: 768px) {
+ .fixed-sidebar {
+ transition: transform 0.3s ease, visibility 0s linear 0s;
+ }
+
+ #main {
+ transition: margin-left 0.3s ease, width 0.3s ease;
+ }
+
+ .sidebar-hidden .fixed-sidebar {
+ transform: translateX(-100%);
+ /* Remove the hidden sidebar from the tab order and the accessibility tree, once it has slid out */
+ visibility: hidden;
+ transition: transform 0.3s ease, visibility 0s linear 0.3s;
+ }
+
+ .sidebar-hidden #main {
+ margin-left: 0;
+ width: 100%;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .fixed-sidebar, #main, .sidebar-toggle-pane {
+ transition: none !important;
}
}
diff --git a/templates/_navbar.html.twig b/templates/_navbar.html.twig
index 7719ab2bf..1560aea92 100644
--- a/templates/_navbar.html.twig
+++ b/templates/_navbar.html.twig
@@ -5,6 +5,18 @@