waybetter/packages/runtime/src/themeBar.ts
CRT 67a8dec7e2 Theme engine v3 step 9: the bar's label mode is measured
themeBar.ts: the sum of the whole labels (each in its own computed font, via canvas
measureText, so a hidden label still counts), icons, paddings and gaps against the
nav's room, with 16px hysteresis; writes html[data-wb-bar=labels|icons] on nav
resize, font loads and window resize. No width breakpoint decides it any more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 05:58:34 +07:00

82 lines
4.9 KiB
TypeScript

// Theme engine v3, step 9 (.omc/plans/theme-engine-v3.md 4.1): one bar component. Whether a bar's
// items carry their labels is measured, never guessed from a width breakpoint: the sum of the whole
// labels (text measured in the label's own font), icons, paddings and gaps against the room the items
// have. The mode lands on `html[data-wb-bar="labels|icons"]`; the bar's CSS keys on it. A label is
// therefore whole or absent, never cut ("H…", 1.0.2).
export const BAR_ATTR = 'data-wb-bar';
export type BarMode = 'labels' | 'icons';
/** One item as the measurer sees it: its label text and font, and the fixed parts around the label. */
export type BarItem = { label: string; font: string; fixed: number };
/**
* Pure: the mode for `items` in `available` px, with `gap` between items. Hysteresis: leaving icons
* mode needs 16px more room than staying in labels mode, so a bar never flaps at the threshold.
*/
export function barMode(items: Array<{ labelWidth: number; fixed: number }>, available: number, gap: number, current: BarMode | null, hysteresis = 16): BarMode {
if (!items.length || !items.some(item => item.labelWidth > 0)) return 'labels';
const need = items.reduce((sum, item) => sum + item.labelWidth + item.fixed, 0) + gap * Math.max(0, items.length - 1);
const room = current === 'icons' ? available - hysteresis : available;
return need <= room ? 'labels' : 'icons';
}
export type BarMeasurerOptions = {
/** The bar's item container (X: the nav). */
nav: string;
/** Items inside it, and inside an item its icon box and label box. */
item: string; icon: string; label: string;
/** Only while this matches (a bar layout is active). */
when?: string;
};
const px = (value: string) => { const n = parseFloat(value); return Number.isFinite(n) ? n : 0; };
/** Measures on nav resize and font loads; writes the mode on <html>. Returns a stop function. */
export function installBarMeasurer(win: Window, options: BarMeasurerOptions): () => void {
const doc = win.document;
const canvas = doc.createElement('canvas');
const ctx = canvas.getContext('2d');
const textWidth = (text: string, font: string) => { if (!ctx) return text.length * 8; ctx.font = font; return Math.ceil(ctx.measureText(text).width); };
let nav: Element | null = null;
let frame = 0;
const measure = () => {
frame = 0;
const html = doc.documentElement;
if (options.when && !doc.querySelector(options.when)) { html.removeAttribute(BAR_ATTR); return; }
if (!nav?.isConnected) return;
const items = [...nav.querySelectorAll(`:scope > ${options.item}`)].filter(el => (el as HTMLElement).offsetParent !== null || el.getClientRects().length);
const measured = items.map(item => {
const label = item.querySelector(options.label) as HTMLElement | null;
const icon = item.querySelector(options.icon) as HTMLElement | null;
const pill = (item.firstElementChild as HTMLElement | null) ?? (item as HTMLElement);
const ps = win.getComputedStyle(pill);
const fixed = px(ps.paddingLeft) + px(ps.paddingRight) + px(ps.borderLeftWidth) + px(ps.borderRightWidth) + (icon ? Math.max(icon.getBoundingClientRect().width, 20) : 0);
if (!label || !label.textContent?.trim()) return { labelWidth: 0, fixed };
const ls = win.getComputedStyle(label);
// The label's own font, read even while the label is display:none (computed styles resolve it).
const font = `${ls.fontStyle} ${ls.fontWeight} ${ls.fontSize} ${ls.fontFamily}`;
return { labelWidth: textWidth(label.textContent.trim(), font) + px(ls.marginLeft) + px(ls.marginRight) + 2, fixed };
});
const ns = win.getComputedStyle(nav);
const available = (nav as HTMLElement).clientWidth - px(ns.paddingLeft) - px(ns.paddingRight);
const current = (html.getAttribute(BAR_ATTR) as BarMode | null);
const mode = barMode(measured, available, px(ns.columnGap) || 0, current);
if (current !== mode) html.setAttribute(BAR_ATTR, mode);
};
const schedule = () => { if (!frame) frame = win.requestAnimationFrame(measure); };
const Resize = (win as Window & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver;
const sized = Resize ? new Resize(schedule) : null;
const attach = () => {
const found = doc.querySelector(options.nav);
if (found !== nav) { if (nav) sized?.unobserve(nav); nav = found; if (nav) sized?.observe(nav); }
schedule();
};
attach();
// The nav mounts late and is replaced on some navigations; items and fonts change rarely.
const timer = win.setInterval(attach, 1500);
const fonts = (doc as Document & { fonts?: FontFaceSet }).fonts;
fonts?.addEventListener?.('loadingdone', schedule);
win.addEventListener('resize', schedule);
return () => { win.clearInterval(timer); sized?.disconnect(); fonts?.removeEventListener?.('loadingdone', schedule); win.removeEventListener('resize', schedule); if (frame) win.cancelAnimationFrame(frame); doc.documentElement.removeAttribute(BAR_ATTR); };
}