feat: two-line cards, responsive grid, real uniform font size
This commit is contained in:
181
src/lib/actions/uniformFontSize.ts
Normal file
181
src/lib/actions/uniformFontSize.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* uniformFontSize — Svelte action that computes and applies a single font size
|
||||
* to all cards in a results grid so the widest content still fits.
|
||||
*
|
||||
* Usage:
|
||||
* <div class="results-grid" use:uniformFontSize={{ results }}>
|
||||
*
|
||||
* How it works:
|
||||
* 1. After each render (via tick()), for every .result-tile in the container:
|
||||
* - Measure available inner width (clientWidth − horizontal padding)
|
||||
* - Measure rendered text width of .result-value and .result-label using
|
||||
* an off-screen span with matching font properties
|
||||
* - Compute scale = availableWidth / max(valueWidth, labelWidth)
|
||||
* 2. Take minimum scale across all tiles (tightest tile wins)
|
||||
* 3. Clamp: min 0.6rem (floor), max = base font size (never inflate)
|
||||
* 4. Set --card-font-size on the container
|
||||
* 5. For any element still overflowing at the floor, add ellipsis + title
|
||||
*
|
||||
* ResizeObserver on the container re-runs measurement, debounced 50 ms.
|
||||
*/
|
||||
|
||||
import { tick } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export interface UniformFontSizeParams {
|
||||
/** Pass the current results array; any change triggers re-measurement. */
|
||||
results: unknown;
|
||||
}
|
||||
|
||||
// Base font size we scale from (1rem). Expressed in px at measure time.
|
||||
const BASE_REM = 1; // rem
|
||||
const FLOOR_REM = 0.6; // rem
|
||||
const DEBOUNCE_MS = 50;
|
||||
|
||||
/** Return the computed horizontal padding (left + right) of an element in px. */
|
||||
function horizontalPadding(el: HTMLElement): number {
|
||||
const style = window.getComputedStyle(el);
|
||||
return parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
|
||||
}
|
||||
|
||||
/** Measure the rendered pixel width of `text` at `fontSize`px using a hidden span. */
|
||||
function measureText(
|
||||
text: string,
|
||||
fontSize: number,
|
||||
fontWeight: string,
|
||||
fontFamily: string,
|
||||
span: HTMLSpanElement
|
||||
): number {
|
||||
span.style.fontSize = `${fontSize}px`;
|
||||
span.style.fontWeight = fontWeight;
|
||||
span.style.fontFamily = fontFamily;
|
||||
span.textContent = text;
|
||||
return span.getBoundingClientRect().width;
|
||||
}
|
||||
|
||||
/** Get root font size in px (for rem→px conversion). */
|
||||
function getRootFontSizePx(): number {
|
||||
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||
}
|
||||
|
||||
export function uniformFontSize(
|
||||
node: HTMLElement,
|
||||
params: UniformFontSizeParams
|
||||
): { update: (p: UniformFontSizeParams) => void; destroy: () => void } {
|
||||
if (!browser) {
|
||||
return { update: () => {}, destroy: () => {} };
|
||||
}
|
||||
|
||||
// Off-screen measurement span — created once, reused, destroyed on action teardown.
|
||||
const measureSpan = document.createElement('span');
|
||||
measureSpan.style.cssText =
|
||||
'position:fixed;top:-9999px;left:-9999px;visibility:hidden;white-space:nowrap;pointer-events:none;';
|
||||
document.body.appendChild(measureSpan);
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let observer: ResizeObserver | null = null;
|
||||
|
||||
async function measure() {
|
||||
// Wait for Svelte to flush DOM updates.
|
||||
await tick();
|
||||
|
||||
const tiles = Array.from(node.querySelectorAll<HTMLElement>('.result-tile'));
|
||||
if (tiles.length === 0) return;
|
||||
|
||||
const rootPx = getRootFontSizePx();
|
||||
const basePx = BASE_REM * rootPx;
|
||||
const floorPx = FLOOR_REM * rootPx;
|
||||
|
||||
let minScale = 1; // never inflate above base
|
||||
|
||||
for (const tile of tiles) {
|
||||
const availableWidth = tile.clientWidth - horizontalPadding(tile);
|
||||
if (availableWidth <= 0) continue;
|
||||
|
||||
const valueEl = tile.querySelector<HTMLElement>('.result-value');
|
||||
const labelEl = tile.querySelector<HTMLElement>('.result-label');
|
||||
if (!valueEl || !labelEl) continue;
|
||||
|
||||
// Get font properties from the live elements (inherits CSS vars etc.)
|
||||
const valueStyle = window.getComputedStyle(valueEl);
|
||||
const labelStyle = window.getComputedStyle(labelEl);
|
||||
|
||||
const valueText = valueEl.textContent ?? '';
|
||||
const labelText = labelEl.textContent ?? '';
|
||||
|
||||
const valueWidth = measureText(
|
||||
valueText,
|
||||
basePx,
|
||||
valueStyle.fontWeight,
|
||||
valueStyle.fontFamily,
|
||||
measureSpan
|
||||
);
|
||||
const labelWidth = measureText(
|
||||
labelText,
|
||||
basePx,
|
||||
labelStyle.fontWeight,
|
||||
labelStyle.fontFamily,
|
||||
measureSpan
|
||||
);
|
||||
|
||||
const maxTextWidth = Math.max(valueWidth, labelWidth);
|
||||
if (maxTextWidth <= 0) continue;
|
||||
|
||||
const scale = availableWidth / maxTextWidth;
|
||||
if (scale < minScale) minScale = scale;
|
||||
}
|
||||
|
||||
// Compute final font size in rem, clamped to floor.
|
||||
const computedPx = Math.max(floorPx, basePx * minScale);
|
||||
const computedRem = computedPx / rootPx;
|
||||
|
||||
node.style.setProperty('--card-font-size', `${computedRem.toFixed(4)}rem`);
|
||||
|
||||
// Apply truncation on elements that still overflow at the floor.
|
||||
if (computedPx <= floorPx + 0.01) {
|
||||
for (const tile of tiles) {
|
||||
for (const selector of ['.result-value', '.result-label']) {
|
||||
const el = tile.querySelector<HTMLElement>(selector);
|
||||
if (!el) continue;
|
||||
if (el.scrollWidth > el.clientWidth) {
|
||||
el.style.overflow = 'hidden';
|
||||
el.style.whiteSpace = 'nowrap';
|
||||
el.style.textOverflow = 'ellipsis';
|
||||
el.title = el.textContent ?? '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleMeasure() {
|
||||
if (debounceTimer !== null) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = null;
|
||||
measure();
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
// Initial measurement (after first render).
|
||||
measure();
|
||||
|
||||
// ResizeObserver — single observer on the grid container.
|
||||
observer = new ResizeObserver(scheduleMeasure);
|
||||
observer.observe(node);
|
||||
|
||||
return {
|
||||
update(_newParams: UniformFontSizeParams) {
|
||||
// Called by Svelte whenever the `results` prop changes — re-measure.
|
||||
measure();
|
||||
},
|
||||
destroy() {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
if (debounceTimer !== null) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = null;
|
||||
}
|
||||
measureSpan.remove();
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user