UNPKG

uicore-ts

Version:

UICore is a library to build native-like user interfaces using pure Typescript. No HTML is needed at all. Components are described as TS classes and all user interactions are handled explicitly. This library is strongly inspired by the UIKit framework tha

328 lines (270 loc) 13.8 kB
import { UIColor } from "./UIColor" import { UICore } from "./UICore" import { UIView } from "./UIView" /** * UITooltip * * Framework-level mouse-following tooltip singleton. * * The default implementation uses a plain HTML element for its content — * no UIView layout system involved. This keeps the base tooltip simple and * allocation-free. * * To use a UIView-based content view instead (e.g. for complex layouts), * override createContentView() to return a UIView subclass. The sizing and * positioning logic in calculateAndSetViewFrame works identically for both * cases since it only calls intrinsicContentHeight/Width on the content view. * * Subclass to provide app-level styling: * - Override createContentView() to return a custom UIView (optional) * - Override applyStyles() to style the default plain-HTML content * - Set UITooltip.sharedInstance to your instance at app startup * * Usage: * UITooltip.sharedInstance.attach(someView, "Tooltip text") * UITooltip.sharedInstance.detach(someView) */ export class UITooltip { // ── Singleton ───────────────────────────────────────────────────────────── static sharedInstance: UITooltip = new UITooltip() // ── Positioning config ──────────────────────────────────────────────────── offsetX: number = 14 offsetY: number = 20 maxWidth: number = 320 // ── Content view ────────────────────────────────────────────────────────── /** * The view that is measured and positioned by calculateAndSetViewFrame. * In the default implementation this is a lightweight UIView wrapper around * a plain HTML label element. Subclasses may replace it with any UIView. */ readonly contentView: UIView // ── Private state ───────────────────────────────────────────────────────── private _isVisible: boolean = false private _mouseX: number = 0 private _mouseY: number = 0 private _isInitialized: boolean = false /** The plain HTML label element used by the default implementation. */ protected _labelElement?: HTMLElement private _attachedHandlers = new WeakMap<UIView, { enter: (sender: UIView, event: Event) => void leave: (sender: UIView, event: Event) => void }>() // ── Constructor ─────────────────────────────────────────────────────────── constructor() { const { contentView, labelElement } = this.createContentView() this.contentView = contentView this._labelElement = labelElement const element = this.contentView.viewHTMLElement element.style.position = "fixed" element.style.pointerEvents = "none" element.style.display = "none" this.contentView.calculateAndSetViewFrame = () => { this._recalculateFrame() } this.applyStyles() } // ── Override points ─────────────────────────────────────────────────────── /** * Creates the content view and optionally a plain HTML label element. * * Default: returns a UIView containing a single <span> whose text is set * directly via innerHTML. The label element is returned so that setText() * can update it without going through the UIView system. * * Override to return a UIView-layout-based content view. In that case, * return labelElement: undefined — setText() will call the UIView's own * text-setting mechanism instead (override setText() too if needed). */ protected createContentView(): { contentView: UIView; labelElement?: HTMLElement } { const labelElement = document.createElement("span") labelElement.style.display = "block" labelElement.style.whiteSpace = "pre-wrap" labelElement.style.wordBreak = "break-word" labelElement.style.lineHeight = "1.4" labelElement.style.color = "#ffffff" labelElement.style.fontSize = "12px" labelElement.style.fontWeight = "400" // Padding is applied to the label element so it sits inset from the // container edges. The measurement in _measureHTMLLabelSize accounts // for this by adding padding * 2 to the measured width and height. const paddingPx = `${(UICore.main?.paddingLength ?? 16) * 0.5}px` labelElement.style.padding = paddingPx labelElement.style.boxSizing = "border-box" const contentView = new UIView() contentView.configureWithObject({ backgroundColor: new UIColor("rgba(30, 30, 40, 0.96)"), style: { borderRadius: "5px", boxShadow: "0 4px 12px rgba(0,0,0,0.25)" } }) contentView.viewHTMLElement.appendChild(labelElement) return { contentView, labelElement } } /** * Apply styles to the default plain-HTML content view. * Only called when using the default createContentView() implementation. * Override alongside createContentView() if you provide a UIView-based * content view that handles its own styling. */ protected applyStyles() {} /** * Sets the tooltip text. Override if using a UIView-based content view * that exposes its own text-setting API. */ setText(text: string) { if (this._labelElement) { this._labelElement.textContent = text } } // ── Sizing — two-pass ───────────────────────────────────────────────────── /** * Returns the display size for the content view given the current text. * * For UIView-based content: delegates to intrinsicContentHeight/Width. * For plain-HTML content: measures the label element directly. * * Two passes: * 1. Height at maxWidth — determines how the text wraps * 2. Width at that height — finds the minimum width that fits the * wrapped text, so short text doesn't leave empty space on the right */ protected measureContentSize(): { width: number; height: number } { if (this._labelElement) { return this._measureHTMLLabelSize() } const height = this.contentView.intrinsicContentHeight(this.maxWidth) const width = Math.min(this.contentView.intrinsicContentWidth(height), this.maxWidth) return { width, height } } private _measureHTMLLabelSize(): { width: number; height: number } { const label = this._labelElement! const prevMaxWidth = label.style.maxWidth const prevWidth = label.style.width // Force into DOM briefly if not connected let wasDetached = false if (!label.isConnected) { document.body.appendChild(label) wasDetached = true } // Pass 1: constrain to maxWidth to get the wrapped height. label.style.maxWidth = `${this.maxWidth}px` label.style.width = "" const wrappedHeight = label.scrollHeight // Pass 2: set both width:fit-content and max-width:innerMax together. // The browser reports the minimum width that fits the content without // exceeding the same constraint used in pass 1 — giving us the width // of the longest wrapped line rather than the full single-line width. // scrollWidth is used instead of getBoundingClientRect().width because // the label's parent UIView has no explicit width set at this point; // getBoundingClientRect() would return a value constrained by the // collapsed parent, resulting in near-zero width and extreme height. label.style.maxWidth = `${this.maxWidth}px` label.style.width = "fit-content" const naturalWidth = label.scrollWidth if (wasDetached) { document.body.removeChild(label) } label.style.maxWidth = prevMaxWidth label.style.width = prevWidth // The label has CSS padding applied (box-sizing: border-box), so // scrollHeight and getBoundingClientRect already include it. return { width: Math.min(Math.ceil(naturalWidth), this.maxWidth), height: wrappedHeight } } private get core() { return UICore.main } // ── Frame calculation ───────────────────────────────────────────────────── private _recalculateFrame() { const { width, height } = this.measureContentSize() const viewportWidth = window.innerWidth const viewportHeight = window.innerHeight const margin = 8 let left = this._mouseX + this.offsetX let top = this._mouseY + this.offsetY if (left + width > viewportWidth - margin) { left = this._mouseX - width - this.offsetX } if (top + height > viewportHeight - margin) { top = this._mouseY - height - this.offsetY * 0.5 } left = Math.max(margin, left) top = Math.max(margin, top) // The element lives directly under document.body (no transformed ancestor), // so position: fixed + translate3d produced by setFrame() is viewport-relative. this.contentView.setFrame( this.contentView.frame .rectangleWithX(Math.round(left)) .rectangleWithY(Math.round(top)) .rectangleWithWidth(width) .rectangleWithHeight(height), 99999 ) } // ── Init ────────────────────────────────────────────────────────────────── private _ensureInitialized() { if (this._isInitialized) { return } this._isInitialized = true // Attach directly to document.body rather than as a UIView subview. // The framework writes all positions via translate3d transforms. Any ancestor // that carries a transform becomes the containing block for position:fixed // descendants, breaking viewport-relative placement. By appending the raw // element to document.body we guarantee no transformed ancestor exists, // so position:fixed + left/top always refer to the viewport. document.body.appendChild(this.contentView.viewHTMLElement) window.addEventListener("mousemove", (event: MouseEvent) => { this._mouseX = event.clientX this._mouseY = event.clientY if (this._isVisible) { this.contentView.calculateAndSetViewFrame() } }, { passive: true }) } // ── Public API ──────────────────────────────────────────────────────────── show(text: string) { this._ensureInitialized() this.setText(text) this.contentView.viewHTMLElement.style.display = "block" if (!this._labelElement) { // UIView-based content needs a layout pass before measuring this.contentView.setNeedsLayout() UIView.layoutViewsIfNeeded() } this._isVisible = true this.contentView.calculateAndSetViewFrame() } hide() { this._isVisible = false this.contentView.viewHTMLElement.style.display = "none" } // ── Attach / detach ─────────────────────────────────────────────────────── attach(view: UIView, text: string) { this.detach(view) const enterHandler = (_sender: UIView, _event: Event) => { this.show(text) } const leaveHandler = (_sender: UIView, _event: Event) => { this.hide() } view.controlEventTargetAccumulator.PointerHover = enterHandler view.controlEventTargetAccumulator.PointerLeave = leaveHandler this._attachedHandlers.set(view, { enter: enterHandler, leave: leaveHandler }) } detach(view: UIView) { const handlers = this._attachedHandlers.get(view) if (!handlers) { return } view.removeTargetForControlEvent(UIView.controlEvent.PointerHover, handlers.enter) view.removeTargetForControlEvent(UIView.controlEvent.PointerLeave, handlers.leave) this._attachedHandlers.delete(view) if (this._isVisible) { this.hide() } } }