From ee5930d72853944ae865a26283eaba41cbf3eca8 Mon Sep 17 00:00:00 2001 From: fs Date: Fri, 13 Mar 2026 21:59:09 +0100 Subject: [PATCH] style: update app-tabs and sidebar components Co-Authored-By: Claude Opus 4.6 --- web/css/components/app-tabs.css | 199 ++++++++++++++++++++++---------- web/css/layout/app-sidebar.css | 3 +- web/js/components/app-tabs.js | 159 +++++++++++++++++++++++-- 3 files changed, 292 insertions(+), 69 deletions(-) diff --git a/web/css/components/app-tabs.css b/web/css/components/app-tabs.css index acec9aa..a7a213f 100644 --- a/web/css/components/app-tabs.css +++ b/web/css/components/app-tabs.css @@ -1,77 +1,154 @@ @layer components { -.app-tabs { - display: flex; - flex-direction: column; - gap: var(--app-spacing); -} - -/* No-JS fallback: show only first tab panel. */ -html.no-js [data-tabs] [data-tab-panel] { - display: none; -} - -html.no-js [data-tabs] [data-tab-panel]:first-of-type { - display: block; -} - -/* JS mode: keep panels hidden until tab init completed (prevents flash). */ -html.js [data-tabs]:not([data-tabs-ready="1"]) [data-tab-panel] { - display: none !important; -} - -.app-tabs-nav { + .app-tabs { display: flex; + flex-direction: column; + gap: var(--app-spacing); + } + + /* No-JS fallback: show only first tab panel. */ + html.no-js [data-tabs] [data-tab-panel] { + display: none; + } + + html.no-js [data-tabs] [data-tab-panel]:first-of-type { + display: block; + } + + /* JS mode: keep panels hidden until tab init completed (prevents flash). */ + html.js [data-tabs]:not([data-tabs-ready="1"]) [data-tab-panel] { + display: none !important; + } + + /* ------------------------------------------------------------------ */ + /* Tab nav wrapper — holds optional chevron buttons + scrollable nav */ + /* ------------------------------------------------------------------ */ + .app-tabs-nav-wrap { + position: relative; + display: flex; + align-items: stretch; border-bottom: 1px solid var(--tabs-border); + } + + /* Chevron scroll buttons — styled like .outline.secondary.small */ + .app-tabs-nav-wrap > .app-tabs-chevron { + flex: 0 0 auto; + display: none; + align-items: center; + justify-content: center; + border: 1px solid var(--app-border); + border-radius: var(--app-border-radius); + background-color: transparent; + color: var(--app-secondary); + font-size: 13px; + line-height: 1; + cursor: pointer; + z-index: 1; + margin-block: auto; + transition: + background-color var(--app-transition), + border-color var(--app-transition), + color var(--app-transition); + } + + .app-tabs-nav-wrap > .app-tabs-chevron:hover, + .app-tabs-nav-wrap > .app-tabs-chevron:focus-visible { + border-color: var(--app-secondary-hover-border); + color: var(--app-secondary-hover); + } + + .app-tabs-nav-wrap > .app-tabs-chevron[disabled] { + opacity: 0.25; + cursor: default; + pointer-events: none; + } + + .app-tabs-nav-wrap > .app-tabs-chevron--start { + order: -1; + margin-inline-end: 4px; + } + + .app-tabs-nav-wrap > .app-tabs-chevron--end { + order: 1; + margin-inline-start: 4px; + } + + /* Show chevrons when overflow is detected */ + .app-tabs-nav-wrap.has-overflow > .app-tabs-chevron { + display: flex; + } + + /* ------------------------------------------------------------------ */ + /* The scrollable tab strip */ + /* ------------------------------------------------------------------ */ + .app-tabs-nav { + display: flex; + border-bottom: 1px solid var(--tabs-border); /* No-JS fallback border */ overflow-x: auto; -webkit-overflow-scrolling: touch; padding-block-start: 2px; padding-left: 2px; white-space: nowrap; -} + flex: 1 1 0%; + scroll-behavior: smooth; + } -.app-tabs-nav button, -.app-tabs-nav a { - background: transparent; - border: 0; - padding: 5px 10px 5px 10px; - color: var(--tabs-muted) !important; - cursor: pointer; - border-bottom: 2px solid transparent; - text-decoration: none; - border-radius: 0; - margin-bottom: 0; -} + /* When wrapped by JS, the wrapper owns the border */ + .app-tabs-nav-wrap > .app-tabs-nav { + border-bottom: none; + } -.app-tabs-nav button:hover, -.app-tabs-nav button:focus-visible, -.app-tabs-nav a:hover, -.app-tabs-nav a:focus-visible { - --app-color: inherit; - color: var(--app-color) !important; -} + /* When JS is active: hide the native scrollbar (chevrons take over) */ + html.js .app-tabs-nav { + scrollbar-width: none; /* Firefox */ + } -.app-tabs-nav button.is-active, -.app-tabs-nav a.is-active { - --app-color: inherit; - color: var(--app-color) !important; - border-color: var(--tabs-active); -} + html.js .app-tabs-nav::-webkit-scrollbar { + display: none; /* Chrome / Safari / Edge */ + } -.app-tabs-nav button.has-invalid, -.app-tabs-nav a.has-invalid { - color: var(--app-danger, #dc3545) !important; -} + .app-tabs-nav button, + .app-tabs-nav a { + background: transparent; + border: 0; + padding: 5px 10px 5px 10px; + color: var(--tabs-muted) !important; + cursor: pointer; + border-bottom: 2px solid transparent; + text-decoration: none; + border-radius: 0; + margin-bottom: 0; + } -[data-tab-panel][hidden] { - display: none; -} + .app-tabs-nav button:hover, + .app-tabs-nav button:focus-visible, + .app-tabs-nav a:hover, + .app-tabs-nav a:focus-visible { + --app-color: inherit; + color: var(--app-color) !important; + } -html.js [data-tabs][data-tabs-ready="1"] [data-tab-panel]:not([hidden]) { - display: block; -} + .app-tabs-nav button.is-active, + .app-tabs-nav a.is-active { + --app-color: inherit; + color: var(--app-color) !important; + border-color: var(--tabs-active); + } -/* Safety fallback when JS is available but the ready marker is missing. */ -html:not(.no-js) [data-tabs] [data-tab-panel]:not([hidden]) { - display: block; -} + .app-tabs-nav button.has-invalid, + .app-tabs-nav a.has-invalid { + color: var(--app-danger, #dc3545) !important; + } + + [data-tab-panel][hidden] { + display: none; + } + + html.js [data-tabs][data-tabs-ready="1"] [data-tab-panel]:not([hidden]) { + display: block; + } + + /* Safety fallback when JS is available but the ready marker is missing. */ + html:not(.no-js) [data-tabs] [data-tab-panel]:not([hidden]) { + display: block; + } } diff --git a/web/css/layout/app-sidebar.css b/web/css/layout/app-sidebar.css index f8d137c..47ddd65 100644 --- a/web/css/layout/app-sidebar.css +++ b/web/css/layout/app-sidebar.css @@ -226,6 +226,7 @@ padding: 0 var(--app-spacing); font-size: var(--app-sidebar-summary-size); color: var(--app-muted-color); + margin: 0; } nav#aside-panel-people { @@ -256,7 +257,7 @@ } .app-sidebar #aside-panel-admin .app-sidebar-admin-group { - margin-bottom: 5px; + margin-bottom: 15px; width: 100%; } diff --git a/web/js/components/app-tabs.js b/web/js/components/app-tabs.js index 8a4973f..073377f 100644 --- a/web/js/components/app-tabs.js +++ b/web/js/components/app-tabs.js @@ -1,10 +1,17 @@ /** - * Initializes tab navigation - * Usage: - * - Tab buttons: - * - Tab panels:
...
- * - Container:
...
- * - URL param: ?tab=system + * Initializes tab navigation with chevron-based overflow scrolling. + * + * Markup (author writes): + *
+ *
+ * + * ... + *
+ *
...
+ *
+ * + * JS wraps the nav in `.app-tabs-nav-wrap` and injects chevron buttons when + * the strip overflows. No-JS fallback: native horizontal scrollbar stays. */ export function initTabs(root = document) { const containers = root.querySelectorAll('[data-tabs]'); @@ -60,6 +67,7 @@ export function initTabs(root = document) { const isActive = tab.dataset.tab === tabName; tab.classList.toggle('is-active', isActive); tab.setAttribute('aria-selected', isActive ? 'true' : 'false'); + tab.setAttribute('tabindex', isActive ? '0' : '-1'); }); panels.forEach(panel => { @@ -81,12 +89,19 @@ export function initTabs(root = document) { } }; + // ---------------------------------------------------------------- + // ARIA: role="tablist" on the nav, not the outer container + // ---------------------------------------------------------------- + const nav = container.querySelector('.app-tabs-nav'); + // Initialize tabs tabs.forEach(tab => { tab.setAttribute('role', 'tab'); tab.addEventListener('click', (e) => { e.preventDefault(); activateTab(tab.dataset.tab); + // Scroll the active tab into view within the nav strip + scrollTabIntoView(tab); }); }); @@ -94,7 +109,37 @@ export function initTabs(root = document) { panel.setAttribute('role', 'tabpanel'); }); - container.setAttribute('role', 'tablist'); + if (nav) { + nav.setAttribute('role', 'tablist'); + } + + // ---------------------------------------------------------------- + // Arrow-key navigation (WAI-ARIA Tabs pattern) + // ---------------------------------------------------------------- + if (nav) { + nav.addEventListener('keydown', (e) => { + const currentIndex = tabs.indexOf(e.target); + if (currentIndex === -1) {return;} + + let nextIndex = -1; + if (e.key === 'ArrowRight') { + nextIndex = (currentIndex + 1) % tabs.length; + } else if (e.key === 'ArrowLeft') { + nextIndex = (currentIndex - 1 + tabs.length) % tabs.length; + } else if (e.key === 'Home') { + nextIndex = 0; + } else if (e.key === 'End') { + nextIndex = tabs.length - 1; + } + + if (nextIndex >= 0) { + e.preventDefault(); + tabs[nextIndex].focus(); + activateTab(tabs[nextIndex].dataset.tab); + scrollTabIntoView(tabs[nextIndex]); + } + }); + } // Activate initial tab if (activeTab) { @@ -102,6 +147,26 @@ export function initTabs(root = document) { } container.dataset.tabsReady = '1'; + // ---------------------------------------------------------------- + // Chevron overflow scrolling + // ---------------------------------------------------------------- + /** @param {HTMLElement} tabEl */ + function scrollTabIntoView(tabEl) { + if (!nav) {return;} + tabEl.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' }); + } + + if (nav) { + initChevronScroll(nav); + + // Once the first tab is activated, ensure it's visible + const activeBtn = nav.querySelector('.is-active'); + if (activeBtn) { + // Defer so layout has settled + requestAnimationFrame(() => scrollTabIntoView(activeBtn)); + } + } + // --- Form validation awareness --- const form = container.closest('form'); if (form) { @@ -219,6 +284,86 @@ export function initTabs(root = document) { }); } +// ------------------------------------------------------------------ +// Chevron-scroll helper — wraps .app-tabs-nav in .app-tabs-nav-wrap +// and injects ‹ / › buttons that appear only when the strip overflows. +// ------------------------------------------------------------------ +const SCROLL_STEP = 160; // px per click +const SCROLL_THRESHOLD = 2; // tolerance for "at edge" detection + +/** @param {HTMLElement} nav The .app-tabs-nav element */ +function initChevronScroll(nav) { + // Wrap: nav → wrap > [chevronStart, nav, chevronEnd] + const wrap = document.createElement('div'); + wrap.className = 'app-tabs-nav-wrap'; + nav.parentNode.insertBefore(wrap, nav); + wrap.appendChild(nav); + + // Create chevron buttons + const btnStart = createChevronBtn('start', 'bi-chevron-left'); + const btnEnd = createChevronBtn('end', 'bi-chevron-right'); + wrap.appendChild(btnStart); + wrap.appendChild(btnEnd); + + // Scroll handlers + btnStart.addEventListener('click', () => { + nav.scrollBy({ left: -SCROLL_STEP, behavior: 'smooth' }); + }); + btnEnd.addEventListener('click', () => { + nav.scrollBy({ left: SCROLL_STEP, behavior: 'smooth' }); + }); + + // Continuous scroll on long-press + addLongPress(btnStart, () => nav.scrollBy({ left: -SCROLL_STEP, behavior: 'smooth' })); + addLongPress(btnEnd, () => nav.scrollBy({ left: SCROLL_STEP, behavior: 'smooth' })); + + // Observe overflow + const update = () => { + const hasOverflow = nav.scrollWidth > nav.clientWidth + SCROLL_THRESHOLD; + wrap.classList.toggle('has-overflow', hasOverflow); + + if (hasOverflow) { + const atStart = nav.scrollLeft <= SCROLL_THRESHOLD; + const atEnd = nav.scrollLeft + nav.clientWidth >= nav.scrollWidth - SCROLL_THRESHOLD; + btnStart.disabled = atStart; + btnEnd.disabled = atEnd; + } + }; + + nav.addEventListener('scroll', update, { passive: true }); + + // ResizeObserver to detect container width changes + if (typeof ResizeObserver !== 'undefined') { + const ro = new ResizeObserver(update); + ro.observe(nav); + } + + // Initial check (deferred so layout is settled) + requestAnimationFrame(update); +} + +/** @returns {HTMLButtonElement} */ +function createChevronBtn(direction, iconClass) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = `app-tabs-chevron app-tabs-chevron--${direction}`; + btn.setAttribute('aria-hidden', 'true'); + btn.setAttribute('tabindex', '-1'); + btn.innerHTML = ``; + return btn; +} + +/** Repeats callback while button is held down */ +function addLongPress(btn, callback) { + let interval = null; + const start = () => { interval = setInterval(callback, 180); }; + const stop = () => { clearInterval(interval); interval = null; }; + btn.addEventListener('pointerdown', start); + btn.addEventListener('pointerup', stop); + btn.addEventListener('pointerleave', stop); + btn.addEventListener('pointercancel', stop); +} + // Auto-initialize on load if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => initTabs());