style: update app-tabs and sidebar components
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user