style: update app-tabs and sidebar components

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 21:59:09 +01:00
parent f4ce9f3378
commit ee5930d728
3 changed files with 292 additions and 69 deletions

View File

@@ -1,10 +1,17 @@
/**
* Initializes tab navigation
* Usage:
* - Tab buttons: <button data-tab="system">System</button>
* - Tab panels: <div data-tab-panel="system">...</div>
* - Container: <div data-tabs>...</div>
* - URL param: ?tab=system
* Initializes tab navigation with chevron-based overflow scrolling.
*
* Markup (author writes):
* <div data-tabs>
* <div class="app-tabs-nav">
* <button data-tab="system" data-tab-default>System</button>
* ...
* </div>
* <div data-tab-panel="system">...</div>
* </div>
*
* 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 = `<i class="bi ${iconClass}"></i>`;
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());