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 @@