/** @odoo-module **/ import publicWidget from "@web/legacy/js/public/public_widget"; /** * M22 Sidebar Widget * Handles the collapsible sidebar functionality with smooth transitions * and proper state management. */ publicWidget.registry.M22Sidebar = publicWidget.Widget.extend({ selector: '#m22_sidebar', events: { 'click .sidebar-collapse-btn': '_onToggleSidebar', 'click .nav-link': '_onLinkClick', }, // Transition duration in ms (should match CSS) TRANSITION_DURATION: 300, /** * @override */ start: function () { this.backdrop = document.getElementById('m22_sidebar_backdrop'); this.collapseBtn = this.el.querySelector('.sidebar-collapse-btn'); this.collapseBtnIcon = this.collapseBtn?.querySelector('i'); // Cache frequently accessed elements this.sidebarTexts = this.el.querySelectorAll('.sidebar-text, .sidebar-title, .sidebar-logo-text'); this.navLinks = this.el.querySelectorAll('.nav-link'); // Setup backdrop listener if (this.backdrop) { this._boundCloseMobile = this._onCloseMobileSidebar.bind(this); this.backdrop.addEventListener('click', this._boundCloseMobile); } // Restore state from localStorage (without animation on page load) this._restoreState(true); // Handle window resize this._boundResize = this._onWindowResize.bind(this); window.addEventListener('resize', this._boundResize); return this._super.apply(this, arguments); }, /** * @override */ destroy: function () { if (this.backdrop && this._boundCloseMobile) { this.backdrop.removeEventListener('click', this._boundCloseMobile); } if (this._boundResize) { window.removeEventListener('resize', this._boundResize); } this._super.apply(this, arguments); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * Restores the sidebar state from localStorage * @param {Boolean} skipAnimation - Whether to skip the collapse animation * @private */ _restoreState: function (skipAnimation = false) { const savedState = localStorage.getItem('m22_sidebar_collapsed'); const isCollapsed = savedState === 'true'; const htmlEl = document.documentElement; const hasEarlyInit = htmlEl.classList.contains('m22-sidebar-collapsed-init'); // If early init already applied collapsed state (sidebar is hidden) if (hasEarlyInit && isCollapsed) { // Sync JS state while sidebar is still hidden this.el.setAttribute('data-collapsed', 'true'); document.body.classList.add('sidebar-collapsed'); // Update icon BEFORE showing sidebar this._updateCollapseIcon(true); // Hide texts this._hideTexts(); // Now show sidebar by adding initialized class // Use double RAF to ensure styles are applied before visibility requestAnimationFrame(() => { requestAnimationFrame(() => { htmlEl.classList.add('m22-sidebar-initialized'); }); }); return; } // Remove early init class if state is expanded (show sidebar immediately) if (hasEarlyInit && !isCollapsed) { // First set expanded state this.el.setAttribute('data-collapsed', 'false'); document.body.classList.remove('sidebar-collapsed'); this._updateCollapseIcon(false); this._showTexts(); // Then show sidebar htmlEl.classList.remove('m22-sidebar-collapsed-init'); } if (skipAnimation && !hasEarlyInit) { // Temporarily disable transitions this.el.style.transition = 'none'; document.body.style.transition = 'none'; } if (!hasEarlyInit) { this._setCollapsedState(isCollapsed, skipAnimation); } if (skipAnimation && !hasEarlyInit) { // Re-enable transitions after a frame requestAnimationFrame(() => { requestAnimationFrame(() => { this.el.style.transition = ''; document.body.style.transition = ''; }); }); } }, /** * Sets the collapsed state of the sidebar * @param {Boolean} isCollapsed - Whether the sidebar should be collapsed * @param {Boolean} skipAnimation - Whether to skip animation * @private */ _setCollapsedState: function (isCollapsed, skipAnimation = false) { const htmlEl = document.documentElement; this.el.setAttribute('data-collapsed', isCollapsed); if (isCollapsed) { document.body.classList.add('sidebar-collapsed'); // Ensure init class is present for collapsed state htmlEl.classList.add('m22-sidebar-collapsed-init'); this._updateCollapseIcon(true); // Hide text immediately when collapsing this._hideTexts(); } else { document.body.classList.remove('sidebar-collapsed'); // Remove init class when expanding to allow normal CSS behavior htmlEl.classList.remove('m22-sidebar-collapsed-init'); this._updateCollapseIcon(false); // Show text with delay to match sidebar expansion if (!skipAnimation) { setTimeout(() => { this._showTexts(); }, this.TRANSITION_DURATION / 2); } else { this._showTexts(); } } }, /** * Updates the collapse button icon based on state * @param {Boolean} isCollapsed * @private */ _updateCollapseIcon: function (isCollapsed) { if (this.collapseBtnIcon) { this.collapseBtnIcon.classList.remove('fa-chevron-left', 'fa-chevron-right', 'fa-bars'); this.collapseBtnIcon.classList.add(isCollapsed ? 'fa-chevron-right' : 'fa-chevron-left'); } }, /** * Hides sidebar text elements * @private */ _hideTexts: function () { this.sidebarTexts.forEach(el => { el.style.opacity = '0'; el.style.visibility = 'hidden'; }); }, /** * Shows sidebar text elements * @private */ _showTexts: function () { this.sidebarTexts.forEach(el => { el.style.opacity = '1'; el.style.visibility = 'visible'; }); }, /** * Checks if we're in mobile view * @returns {Boolean} * @private */ _isMobile: function () { return window.innerWidth < 992; }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * Toggles the sidebar collapsed state * @param {Event} ev * @private */ _onToggleSidebar: function (ev) { ev.preventDefault(); ev.stopPropagation(); if (this._isMobile()) { // On mobile, toggle the overlay sidebar const isOpen = this.el.classList.contains('m22-sidebar-open'); if (isOpen) { this._onCloseMobileSidebar(); } else { this._onOpenMobileSidebar(); } } else { // On desktop, toggle collapse state const isCollapsed = this.el.getAttribute('data-collapsed') === 'true'; const newState = !isCollapsed; this._setCollapsedState(newState); localStorage.setItem('m22_sidebar_collapsed', newState); } }, /** * Closes sidebar on mobile when a link is clicked * @private */ _onLinkClick: function () { if (this._isMobile() && this.el.classList.contains('m22-sidebar-open')) { this._onCloseMobileSidebar(); } }, /** * Opens the mobile sidebar * @private */ _onOpenMobileSidebar: function () { this.el.classList.add('m22-sidebar-open'); if (this.backdrop) { this.backdrop.classList.add('visible'); } document.body.style.overflow = 'hidden'; }, /** * Closes the mobile sidebar * @private */ _onCloseMobileSidebar: function () { this.el.classList.remove('m22-sidebar-open'); if (this.backdrop) { this.backdrop.classList.remove('visible'); } document.body.style.overflow = ''; }, /** * Handle window resize * @private */ _onWindowResize: function () { // Close mobile sidebar if resizing to desktop if (!this._isMobile() && this.el.classList.contains('m22-sidebar-open')) { this._onCloseMobileSidebar(); } } }); /** * External trigger widget for opening the sidebar on mobile * Use data-action="open-m22-sidebar" on any element */ publicWidget.registry.M22SidebarTrigger = publicWidget.Widget.extend({ selector: '[data-action="open-m22-sidebar"]', events: { 'click': '_onTriggerClick', }, _onTriggerClick: function (ev) { ev.preventDefault(); const sidebar = document.getElementById('m22_sidebar'); const backdrop = document.getElementById('m22_sidebar_backdrop'); if (sidebar) { sidebar.classList.add('m22-sidebar-open'); if (backdrop) { backdrop.classList.add('visible'); } document.body.style.overflow = 'hidden'; } } });