Files
breadcrumb-the-shire/web/js/components/app-list-tabs.js
fs cfba3f40ed 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>
2026-04-23 23:50:59 +02:00

92 lines
2.9 KiB
JavaScript

/**
* 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 };
}