feat(responsive): mobile polish — topbar, drawer, footer, tabs, notification dropdown
Unifies the breakpoint system and fixes cross-layer CSS bugs that kept the old mobile hide rules from ever taking effect. Breakpoints: - Document canonical xs/sm/md/lg/xl/2xl scale in variables.base.css - Replace 968px one-offs with 1024px (app-details, app-page-editor) - Make details tab-panel max-width responsive (min(70ch, 100%)) Topbar (< md): - Hide breadcrumb in components layer so cross-layer overrides actually win - Dock search icon alongside right-column icons via flex layout - Add xs (400px) tightening for hit-area preservation - Hide brand divider alongside hidden brand name - Switch hover states to theme-aware --app-dropdown-hover-background-color Mobile drawer: - Icon-bar now vertical like desktop (was horizontal top strip, effectively invisible) - Sidebar shifted 52px right so icon-bar and sidebar slide in as one 280px unit - Add breathing room above first nav item (20px top padding) List tabs: - Rewrite .app-list-tabs to horizontal scroll with hidden scrollbar, snap, and mask-image gradient fades; add initListTabs to scroll active into view - Register as runtime component in app-init.js List toolbar: - Stack wide controls (multi-select, search/text inputs) full-width below sm, keep compact controls at natural width Footer: - New dedicated app-footer.css with mobile-first stacking, border-top separator, theme-aware hover; remove redundant rules from app-shell.css Notification dropdown: - Width with min(360px, 100vw - 2rem) and min-width: 0 override global details > ul rule; switch to position: fixed below md so it hugs the viewport right edge instead of the non-rightmost bell button Content spacing: - Add padding-block-start to .app-main-content on mobile (was 0) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
91
web/js/components/app-list-tabs.js
Normal file
91
web/js/components/app-list-tabs.js
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* List page tab bar — horizontal scroll behavior.
|
||||
* Keeps tabs on a single line, scrolls the active tab into view on mount,
|
||||
* and toggles has-overflow/at-start/at-end classes that drive the CSS
|
||||
* gradient fade masks.
|
||||
*/
|
||||
import { resolveHost } from '../core/app-dom.js';
|
||||
|
||||
const BOUND_ATTR = 'data-app-list-tabs-bound';
|
||||
const EDGE_TOLERANCE = 1;
|
||||
|
||||
const updateOverflow = (el) => {
|
||||
const hasOverflow = el.scrollWidth - el.clientWidth > EDGE_TOLERANCE;
|
||||
el.classList.toggle('has-overflow', hasOverflow);
|
||||
if (!hasOverflow) {
|
||||
el.classList.remove('at-start', 'at-end');
|
||||
return;
|
||||
}
|
||||
const atStart = el.scrollLeft <= EDGE_TOLERANCE;
|
||||
const atEnd = el.scrollLeft + el.clientWidth >= el.scrollWidth - EDGE_TOLERANCE;
|
||||
el.classList.toggle('at-start', atStart);
|
||||
el.classList.toggle('at-end', atEnd);
|
||||
};
|
||||
|
||||
const scrollActiveIntoView = (el) => {
|
||||
const active = el.querySelector('.is-active');
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
const activeCenter = active.offsetLeft + active.offsetWidth / 2;
|
||||
const target = activeCenter - el.clientWidth / 2;
|
||||
const max = el.scrollWidth - el.clientWidth;
|
||||
el.scrollLeft = Math.max(0, Math.min(target, max));
|
||||
};
|
||||
|
||||
export function initListTabs(root = document, options = {}) {
|
||||
const selector = String(options.selector || '.app-list-tabs').trim() || '.app-list-tabs';
|
||||
const host = resolveHost(root);
|
||||
const cleanupFns = [];
|
||||
const bound = [];
|
||||
|
||||
const tabs = Array.from(host.querySelectorAll(selector));
|
||||
tabs.forEach((el) => {
|
||||
if (el.getAttribute(BOUND_ATTR) === '1') {
|
||||
return;
|
||||
}
|
||||
el.setAttribute(BOUND_ATTR, '1');
|
||||
bound.push(el);
|
||||
|
||||
// Initial scroll-into-view uses instant positioning (no smooth behavior
|
||||
// before the strip has finished layout — smooth kicks in on user scroll).
|
||||
const previousBehavior = el.style.scrollBehavior;
|
||||
el.style.scrollBehavior = 'auto';
|
||||
scrollActiveIntoView(el);
|
||||
el.style.scrollBehavior = previousBehavior;
|
||||
|
||||
updateOverflow(el);
|
||||
|
||||
const onScroll = () => updateOverflow(el);
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
cleanupFns.push(() => el.removeEventListener('scroll', onScroll));
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const resizeObserver = new ResizeObserver(() => updateOverflow(el));
|
||||
resizeObserver.observe(el);
|
||||
cleanupFns.push(() => resizeObserver.disconnect());
|
||||
} else {
|
||||
const onResize = () => updateOverflow(el);
|
||||
window.addEventListener('resize', onResize, { passive: true });
|
||||
cleanupFns.push(() => window.removeEventListener('resize', onResize));
|
||||
}
|
||||
});
|
||||
|
||||
const destroy = () => {
|
||||
cleanupFns.forEach((fn) => {
|
||||
try {
|
||||
fn();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
cleanupFns.length = 0;
|
||||
bound.forEach((el) => {
|
||||
el.removeAttribute(BOUND_ATTR);
|
||||
el.classList.remove('has-overflow', 'at-start', 'at-end');
|
||||
});
|
||||
bound.length = 0;
|
||||
};
|
||||
|
||||
return { destroy };
|
||||
}
|
||||
Reference in New Issue
Block a user