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
132 changes: 91 additions & 41 deletions assets/controllers/common/hide_sidebar_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,75 +21,125 @@

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}
* @private
*/
_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 = '<i class="fas fa-angle-right"></i>';
/**
* 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 = '<i class="fas fa-angle-left"></i>';
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) {

Check warning on line 118 in assets/controllers/common/hide_sidebar_controller.js

View workflow job for this annotation

GitHub Actions / JavaScript static analysis

'e' is defined but never used
return this.hidden;
}
}

_saveState(hidden) {
try {
localStorage.setItem(STORAGE_KEY, hidden ? 'true' : 'false');
} catch (e) {

Check warning on line 126 in assets/controllers/common/hide_sidebar_controller.js

View workflow job for this annotation

GitHub Actions / JavaScript static analysis

'e' is defined but never used
//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'));
}
}
}
59 changes: 51 additions & 8 deletions assets/css/app/layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
<html> 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;
}
}

Expand Down
12 changes: 12 additions & 0 deletions templates/_navbar.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@
<nav class="navbar navbar-expand-md bg-body-tertiary border-bottom shadow-sm fixed-top py-0" id="navbar">
<div class="container-fluid">
<div class="d-flex justify-content-start align-items-center">
{# On screens which show the sidebar permanently, it can be slid away with this button #}
<button class="btn btn-sm d-none d-md-inline-flex" type="button" id="sidebar-toggle-button"
title="{% trans %}sidebar.big.toggle{% endtrans %} ([)" aria-label="{% trans %}sidebar.big.toggle{% endtrans %}"
aria-controls="fixed-sidebar" aria-expanded="true"
{{ stimulus_controller('common/hide_sidebar') }} {{ stimulus_action('common/hide_sidebar', 'toggleSidebar') }}>
{# A window with a sidebar pane, which is filled while the sidebar is shown (Font Awesome Free has no such icon) #}
<svg width="20" height="20" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.25" aria-hidden="true">
<path class="sidebar-toggle-pane" stroke="none" fill="currentColor" d="M3.5 2.5H6v11H3.5a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2z"/>
<rect x="1.5" y="2.5" width="13" height="11" rx="2"/>
<path d="M6 2.5v11"/>
</svg>
</button>
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#sidebar-container">
<span class="visually-hidden">{% trans %}sidebar.toggle{% endtrans %}</span>
<!-- <span class="navbar-toggler-icon"></span> -->
Expand Down
16 changes: 10 additions & 6 deletions templates/base.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@
data-locale="{{ app.request.locale|default("en")|slice(0,2) }}"
data-keybindings-special-characters="{{ settings_instance('keybindings').enableSpecialCharacters ? 'true' : 'false' }}">

{# Apply the persisted sidebar state before anything is rendered, so that a hidden sidebar does not flash up on page load.
The class lives on the <html> element, so it survives Turbo navigations (which only swap the body). #}
<script nonce="{{ csp_nonce('script') }}" data-turbo-eval="false">
try {
if (localStorage.getItem('hide_sidebar') === 'true') {
document.documentElement.classList.add('sidebar-hidden');
}
} catch (e) {}
</script>

{# Listen for the special #}
{% if is_granted("@tools.label_scanner") %}
<form class="d-none" {{ stimulus_controller('helpers/scan_special_char') }} action="{{ path("scan_dialog") }}" data-turbo-frame="content">
Expand Down Expand Up @@ -133,12 +143,6 @@
<i class="fas fa-angle-up fa-fw"></i>
</button>

{# Must be outside of the sidebar or it will be hidden too #}
<button class="btn btn-outline-secondary btn-sm p-0 d-md-block d-none" type="button" id="sidebar-toggle-button" title="{% trans %}sidebar.big.toggle{% endtrans %}"
{{ stimulus_controller('common/hide_sidebar') }} {{ stimulus_action('common/hide_sidebar', 'toggleSidebar') }} style="--fa-width: 10px;">
<i class="fas fa-angle-left"></i>
</button>

{% endblock %}

</body>
Expand Down
Loading