0 directories, 46 files

util

Home / tinai / util
/**
 * DOM utility helpers for element selection, manipulation, and safe event handling.
 */

/**
 * Safely gets an element by its ID.
 * @param {string} id - Element ID.
 * @returns {HTMLElement|null} The DOM element or null if not found.
 */
export function getEl(id) {
	if (!id || typeof id !== 'string') return null;
	return document.getElementById(id);
}

/**
 * Safely queries a single descendant element.
 * @param {string} selector - CSS selector.
 * @param {Element|Document} [parent=document] - Parent element to query from.
 * @returns {HTMLElement|Element|null} The matched element or null.
 */
export function queryEl(selector, parent = document) {
	if (!selector || !parent || typeof parent.querySelector !== 'function') return null;
	return parent.querySelector(selector);
}

/**
 * Safely queries all matching descendant elements as an Array.
 * @param {string} selector - CSS selector.
 * @param {Element|Document} [parent=document] - Parent element to query from.
 * @returns {Element[]} Array of matched elements.
 */
export function queryAll(selector, parent = document) {
	if (!selector || !parent || typeof parent.querySelectorAll !== 'function') return [];
	return [...parent.querySelectorAll(selector)];
}

/**
 * Toggles a class on an element if the element exists.
 * @param {Element|null} el - Target element.
 * @param {string} className - Class name to toggle.
 * @param {boolean} [force] - Optional force boolean.
 * @returns {boolean} Whether the class is present after toggling.
 */
export function toggleElementClass(el, className, force) {
	if (!el || !el.classList || !className) return false;
	if (typeof force === 'boolean') {
		return el.classList.toggle(className, force);
	}
	return el.classList.toggle(className);
}

/**
 * Sets the display style on an element.
 * @param {HTMLElement|Element|null} el - Target element.
 * @param {string} display - CSS display value.
 */
export function setElementDisplay(el, display) {
	if (el && el.style) {
		el.style.display = display;
	}
}

/**
 * Creates a DOM element from an HTML string.
 * @param {string} htmlString - Valid HTML string.
 * @returns {HTMLElement|Element|null} The parsed root element or null.
 */
export function createElementFromHTML(htmlString) {
	if (!htmlString || typeof htmlString !== 'string') return null;
	const template = document.createElement('template');
	template.innerHTML = htmlString.trim();
	return template.content.firstElementChild;
}

/**
 * Safely attaches an event listener to an element with existence verification.
 * @param {EventTarget|null} el - Target element or event target.
 * @param {string} event - Event name.
 * @param {Function} handler - Event handler function.
 * @param {object|boolean} [options] - Optional addEventListener options.
 */
export function addSafeEventListener(el, event, handler, options) {
	if (el && typeof el.addEventListener === 'function' && typeof handler === 'function') {
		el.addEventListener(event, handler, options);
	}
}

/**
 * Updates a range input's --range-fill custom property to the percentage of
 * its track that is filled. The themed slider track gradient (WebKit) reads
 * this variable; Firefox paints the fill via -moz-range-progress instead.
 * @param {HTMLInputElement|null} input - The range input.
 */
export function update_range_fill(input) {
	if (!input || input.type !== 'range') return;
	const min = parseFloat(input.min) || 0;
	const max = parseFloat(input.max);
	const value = parseFloat(input.value);
	let percent = 0;
	if (Number.isFinite(max) && max > min && Number.isFinite(value)) {
		percent = ((value - min) / (max - min)) * 100;
	}
	input.style.setProperty('--range-fill', Math.min(100, Math.max(0, percent)) + '%');
}

/**
 * Renders the trailing tokens of a live token stream as spans with an
 * increasing opacity fade (oldest 20% to newest 100%).
 * @param {HTMLElement|null} container - Target element; left empty when there are no tokens.
 * @param {string[]} tokens - All tokens received so far.
 * @param {number} [count=5] - Number of trailing tokens to display.
 */
export function render_token_fade(container, tokens, count = 5) {
	if (!container) return;
	container.innerHTML = '';
	const last = Array.isArray(tokens) ? tokens.slice(-count) : [];
	const opacities = [0.2, 0.4, 0.6, 0.8, 1];
	const offset = opacities.length - last.length;
	last.forEach((token, i) => {
		const span = document.createElement('span');
		span.textContent = token;
		span.style.opacity = String(opacities[offset + i]);
		container.appendChild(span);
	});
}

/**
 * Strips HTML tags from a string, leaving the text content.
 * @param {string} html - The html string.
 * @returns {string} The plain text.
 */
export function strip_tags(html) {
	const holder = document.createElement('div');
	holder.innerHTML = html || '';
	return holder.textContent || '';
}

/**
 * Downloads content as a file through a temporary blob URL.
 * @param {string} name - The download filename (with extension).
 * @param {string} mime - The content MIME type.
 * @param {string|ArrayBufferView} content - The file content.
 */
export function download_file(name, mime, content) {
	const blob = new Blob([content], { type: mime });
	const url = URL.createObjectURL(blob);
	const link = document.createElement('a');
	link.href = url;
	link.download = name;
	link.click();
	URL.revokeObjectURL(url);
}

let measure_ctx = null;

/**
 * Reports whether a label fits inside an element at its current width,
 * measured with the element's own font, padding and border.
 * @param {HTMLElement} el - The element whose width and font are measured.
 * @param {string} text - The label to measure.
 * @param {number} extra_em - Extra allowance in ems for chrome such as a
 *   select's picker icon.
 * @returns {boolean} True when the label fits.
 */
function label_fits(el, text, extra_em) {
	const cs = getComputedStyle(el);
	measure_ctx ||= document.createElement('canvas').getContext('2d');
	measure_ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
	const box = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight)
		+ parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth)
		+ parseFloat(cs.fontSize) * extra_em;
	return el.clientWidth >= measure_ctx.measureText(text.toUpperCase()).width + box;
}

/**
 * Swaps a base-select's placeholder option text to a compact label while the
 * control is too narrow to fit the full label next to its picker icon, and
 * back when space returns. The fit is measured with the select's own font.
 * @param {HTMLSelectElement|null} select - The select to watch.
 * @param {string} full - The full placeholder label.
 * @param {string} compact - The compact label for tight widths.
 */
export function watch_compact_select_label(select, full, compact) {
	if (!select) return;
	const placeholder = select.querySelector('option[value=""]');
	if (!placeholder) return;
	// picker icon plus its gap, roughly one and a half em
	new ResizeObserver(() => {
		placeholder.textContent = label_fits(select, full, 1.6) ? full : compact;
	}).observe(select);
}

/**
 * Swaps a button's label to a compact label while the button is too narrow to
 * fit the full label, and back when space returns. A label set to anything
 * else at runtime (e.g. Edit for the preview toggle) is left untouched.
 * @param {HTMLButtonElement|null} button - The button to watch.
 * @param {string} full - The full label.
 * @param {string} compact - The compact label for tight widths.
 */
export function watch_compact_button_label(button, full, compact) {
	if (!button) return;
	const swap = () => {
		const want = label_fits(button, full, 0) ? full : compact;
		if ((button.textContent === full || button.textContent === compact) && button.textContent !== want) {
			button.textContent = want;
		}
	};
	// childList mutations re-run the swap after a runtime label change such as
	// the Edit/Preview toggle, without waiting for a resize
	new MutationObserver(swap).observe(button, { childList: true });
	new ResizeObserver(swap).observe(button);
}

/**
 * Returns whether the keyboard focus is inside any of the given roots.
 * Shared by the editors' contains_focus checks guarding panel state and
 * saves against unfocused rebuilds.
 * @param {...(HTMLElement|null)} roots - The container elements to test.
 * @returns {boolean} True when document.activeElement lies inside one of them.
 */
export function contains_focus(...roots) {
	return roots.some(root => !!(root && root.contains(document.activeElement)));
}
🌐
dom-utils.js ×
Type: Web, text/plain
8.33 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File