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>
This commit is contained in:
parent
27952d60a8
commit
67a8dec7e2
2 changed files with 104 additions and 0 deletions
82
packages/runtime/src/themeBar.ts
Normal file
82
packages/runtime/src/themeBar.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// 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); };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue