0 directories, 46 files

util

Home / tinai / util
/**
 * Class SideDrawer
 * Shared slide-in drawer for editing surfaces: an overlay panel that slides
 * in from the left of its container with a grip toggle on its right edge,
 * plus a pin that keeps it docked open beneath the surface's sticky header.
 * Consumers own the content through the content slot API; an empty drawer
 * cannot be opened (dimmed grip) and cannot be pinned.
 *
 * While visible, the drawer's parent container carries the
 * side-drawer-active class so consumer CSS can reserve space for the closed
 * grip track and dock the drawer when pinned. An optional top offset
 * provider (bound with the root) keeps the drawer clear of a sticky header
 * on the surface above it.
 */
import { content_of } from './scrollbars.js';

/**
 * Returns the cumulative CSS zoom applied over an element and its ancestors,
 * which scales its rendered geometry relative to its local px units.
 * @param {HTMLElement} el - The element whose rendered scale is measured.
 * @returns {number} The cumulative zoom factor (1 when unzoomed).
 */
function zoom_scale_of(el) {
	let scale = 1;
	for (let node = el; node; node = node.parentElement) {
		const zoom = parseFloat(getComputedStyle(node).zoom);
		if (zoom > 0) scale *= zoom;
	}
	return scale;
}

/**
 * Class SideDrawer
 * The shared slide-in panel used by the thinking panels and the scratchpad
 * table of contents. One instance wraps one drawer element (see shared.css
 * .div-side-drawer and the shared host rules there): grip to open and close,
 * optional pin to dock it into the host's flex flow, availability dimming
 * while empty, and top-offset handling under sticky headers. Hosts declare
 * the drawer variables (--side-drawer-*) from shared.css. Editors drive it
 * through ThinkingPanelBase rather than directly, so their panels stay
 * uniform; app.js binds the scratchpad drawer itself.
 */
class SideDrawer {

	#root = null;
	#grip = null;
	#pin = null;
	#get_top_offset = null;
	#source = null;
	#open = false;
	#pinned = false;
	#labels = { open: 'Close Panel', close: 'Open Panel' };

	/**
	 * Returns the drawer's content slot: resolved live because
	 * SimpleBar may restructure the content element's children.
	 * @returns {HTMLElement|null} The element to write content into.
	 */
	#content_el() {
		return this.#root ? content_of(this.#root.querySelector('.div-side-drawer-content')) : null;
	}

	/**
	 * Binds the drawer chrome: grip toggle, pin button, and the
	 * resize-driven availability updates.
	 * @param {HTMLElement} root - The drawer root element (.div-side-drawer).
	 * @param {function(): number|null} [get_top_offset] - Returns the drawer's
	 *   top inset in rendered (viewport) px to clear a sticky header, or null
	 *   for the CSS default.
	 * @param {Object} [labels] - Grip titles { open, close }: the title shown
	 *   while the drawer is open (the close action) and while closed (the
	 *   open action), overriding the defaults.
	 */
	bind(root, get_top_offset = null, labels = null) {
		this.#root = root;
		this.#grip = root.querySelector('.div-side-drawer-grip');
		this.#pin = root.querySelector('.div-side-drawer-pin');
		this.#get_top_offset = get_top_offset;
		this.#labels = { open: 'Close Panel', close: 'Open Panel', ...labels };

		if (this.#grip) {
			this.#grip.addEventListener('click', (e) => {
				e.stopPropagation();
				if (!this.is_empty()) {
					this.set_open(!this.#open);
					return;
				}
				// empty drawer: the grip only closes it, never opens it
				if (this.#open) this.set_open(false);
			});
		}
		if (this.#pin) {
			this.#pin.addEventListener('click', (e) => {
				e.stopPropagation();
				this.set_pinned(!this.#pinned);
			});
		}
		window.addEventListener('resize', () => this.update_availability());
		// the sticky header's height follows the container's width (panel
		// toggles, rewraps) without a window resize; re-measure when it does
		if (root.parentElement) {
			new ResizeObserver(() => this.update_availability()).observe(root.parentElement);
		}
	}

	/**
	 * Records what the current content belongs to (e.g. the conversation
	 * guid the drawer was filled from), so consumers can detect stale
	 * content when the selection changes.
	 * @param {string|null} source - Identifier of the content's owner.
	 */
	set_source(source) {
		this.#source = source;
	}

	/**
	 * Returns the identifier of the content's owner (see set_source).
	 * @returns {string|null} The source identifier, or null when untracked.
	 */
	get source() {
		return this.#source;
	}

	/**
	 * Returns whether the drawer has no content.
	 * @returns {boolean} True when the content slot is empty.
	 */
	is_empty() {
		const content = this.#content_el();
		return !content || content.children.length === 0;
	}

	/**
	 * Returns whether the drawer is currently slid open.
	 * @returns {boolean} True when open.
	 */
	is_open() {
		return this.#open;
	}

	/**
	 * Slides the drawer in or out and updates the grip label.
	 * @param {boolean} open - True to show the panel, false to collapse it to the grip track.
	 */
	set_open(open) {
		this.#open = open;
		this.#root?.classList.toggle('side-open', open);
		const label = open ? this.#labels.open : this.#labels.close;
		if (this.#grip) {
			this.#grip.title = label;
		}
	}

	/**
	 * Toggles the drawer between open and closed.
	 */
	toggle() {
		this.set_open(!this.#open);
	}

	/**
	 * Pins the drawer: it stays docked open as an overlay beneath the
	 * surface's sticky header instead of sliding out over the content.
	 * Unpinning returns it to the sliding overlay.
	 * @param {boolean} pinned - Whether the drawer is pinned.
	 */
	set_pinned(pinned) {
		this.#pinned = pinned;
		this.#root?.classList.toggle('pinned', pinned);
		if (this.#pin) {
			this.#pin.title = pinned ? 'Unpin' : 'Pin';
		}
	}

	/**
	 * Shows or hides the whole drawer for the current surface, independent
	 * of its open/pinned state.
	 * @param {boolean} visible - Whether the drawer is available on the current surface.
	 */
	set_visible(visible) {
		if (!this.#root) return;
		this.#root.style.display = visible ? '' : 'none';
		this.#root.parentElement?.classList.toggle('side-drawer-active', visible);
		if (visible) {
			this.update_availability();
		}
	}

	/**
	 * Replaces the drawer content with the given element.
	 * @param {HTMLElement|null} element - The content element, or null to clear.
	 * @param {object} [options=null] - Optional behaviour.
	 * @param {boolean} [options.close_if_empty=false] - Also close and unpin the
	 *   drawer when the new content is empty, so an emptied drawer does not
	 *   linger open on the surface.
	 */
	set_content(element, options = null) {
		const content = this.#content_el();
		if (!content) return;
		content.innerHTML = '';
		if (element) {
			content.appendChild(element);
		}
		if (options?.close_if_empty && !element) {
			// nothing to show: an empty drawer must not stay open or pinned
			this.set_open(false);
			this.set_pinned(false);
		}
		this.update_availability();
		if (element && this.#pin) {
			// a populated drawer always ends with the pin visible: the pass
			// above can measure a still-settling layout right after load
			this.#pin.style.display = '';
		}
	}

	/**
	 * Replaces the drawer content with the given html.
	 * @param {string} html - The html to render into the drawer.
	 */
	set_content_html(html) {
		const content = this.#content_el();
		if (!content) return;
		content.innerHTML = html;
		this.update_availability();
	}

	/**
	 * Clears the drawer content.
	 */
	clear() {
		this.set_content(null);
	}

	/**
	 * Updates the drawer's dynamic state: the sticky-header top offset, the
	 * empty state (an empty drawer cannot be opened — dimmed grip, open
	 * drawer stays closable), and the pin button, which only appears when
	 * the drawer's share of the container width can fit its minimum width —
	 * otherwise it is hidden and any pinned state is cleared. The pin stays
	 * put while the content refills, so switching surfaces does not make it
	 * flicker. The empty and pin checks are skipped while the drawer is
	 * hidden, since layout is then unmeasurable; set_visible(true) re-runs
	 * them.
	 */
	update_availability() {
		this.#apply_top_offset();
		if (!this.#root) return;
		// the empty state is layout-free, so it stays correct even while the
		// drawer's surface is hidden and measurements are impossible
		this.#root.classList.toggle('empty', this.is_empty());
		if (!this.#root.offsetParent) return;
		// two- and three-column layouts always fit the pin; only the narrow
		// single-column surface hides it
		const pin_feasible = window.innerWidth > 780;
		if (this.#pin) {
			this.#pin.style.display = pin_feasible ? '' : 'none';
		}
		if (!pin_feasible && this.#pinned) {
			this.set_pinned(false);
		}
	}

	/**
	 * Applies the bound top offset provider, keeping the drawer below a
	 * sticky header on the surface; a null result restores the CSS default.
	 * The offset arrives in rendered (viewport) px, while style.top is
	 * interpreted in the drawer's local units, so it is divided by the
	 * cumulative CSS zoom. The bottom inset is mirrored on top so the drawer
	 * floats evenly between header and container edge.
	 * @private
	 */
	#apply_top_offset() {
		if (!this.#root || !this.#get_top_offset) return;
		const offset = this.#get_top_offset();
		if (offset === null || offset === undefined) {
			this.#root.style.top = '';
			return;
		}
		const gap = parseFloat(getComputedStyle(this.#root).bottom) || 0;
		this.#root.style.top = offset / zoom_scale_of(this.#root) + gap + 'px';
	}
}

export default SideDrawer;
🌐
side-drawer.js ×
Type: Web, text/plain
9.37 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File