UNPKG

@needle-tools/engine

Version:

Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.

162 lines 6.75 kB
import { isComponent } from "./engine_types.js"; /** * Manages an accessible, screen-reader-friendly overlay for a Needle Engine {@link Context}. * * The manager maintains a visually-hidden DOM tree that mirrors relevant 3D scene objects * with appropriate ARIA roles and labels. It also provides a live region so that hover * events in the 3D scene can be announced to assistive technology without stealing focus. * * ## Automatic integration * Several built-in components register accessible elements automatically: * - {@link DragControls} — announces draggable objects and drag state * - {@link Button} — exposes UI buttons to the accessibility tree * - {@link Text} — exposes UI text content to screen readers * - {@link ChangeTransformOnClick} — announces clickable transform actions * - {@link ChangeMaterialOnClick} — announces clickable material changes * - {@link EmphasizeOnClick} — announces clickable emphasis effects * - {@link PlayAudioOnClick} — announces clickable audio playback * - {@link PlayAnimationOnClick} — announces clickable animation triggers * * ## What this unlocks * - Hovering over buttons and interactive objects with the cursor announces them to screen readers via an ARIA live region — no focus steal required * - Screen readers can discover and navigate interactive 3D objects in the scene * - Drag operations update the accessibility state (busy, label changes) in real time * - Custom components can participate by calling {@link updateElement}, {@link focus}, and {@link hover} * * Access the manager via `this.context.accessibility` from any component. */ export class AccessibilityManager { context; static _managers = new WeakMap(); /** Returns the {@link AccessibilityManager} associated with the given context or component. */ static get(obj) { if (isComponent(obj)) { return this._managers.get(obj.context); } else { return this._managers.get(obj); } } constructor(context) { this.context = context; this.root.style.cssText = ` position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; `; this.root.setAttribute("role", "region"); this.root.setAttribute("aria-label", "3D Needle Engine scene"); // Live region for announcing hovered 3D elements without stealing focus this.liveRegion.setAttribute("aria-live", "polite"); this.liveRegion.setAttribute("aria-atomic", "true"); this.liveRegion.setAttribute("role", "status"); this.root.appendChild(this.liveRegion); this.enabled = true; } _enabled; /** Enables or disables the accessibility overlay. When disabled, the overlay DOM is removed. */ set enabled(value) { if (value === this._enabled) return; this._enabled = value; if (!value) { this.root.remove(); } else { AccessibilityManager._managers.set(this.context, this); const target = this.context.domElement.shadowRoot || this.context.domElement; target.prepend(this.root); } } /** Removes all tracked accessibility elements, keeping only the live region. */ clear() { this.root.childNodes.forEach(child => child.remove()); this.root.appendChild(this.liveRegion); } /** Removes the overlay from the DOM and unregisters this manager from the context. */ dispose() { this.root.remove(); AccessibilityManager._managers.delete(this.context); } root = document.createElement("div"); liveRegion = document.createElement("div"); treeElements = new WeakMap(); /** * Creates or updates the accessible DOM element for a 3D object or component. * @param obj - The scene object or component to represent. * @param data - Partial accessibility data (role, label, hidden, busy) to apply. */ updateElement(obj, data) { let el = this.treeElements.get(obj); if (!el) { el = document.createElement("div"); this.treeElements.set(obj, el); this.root.appendChild(el); let didSetRole = false; if (typeof data === "object") { if (data.role) { didSetRole = true; el.setAttribute("role", data.role); } if (data.label) { el.setAttribute("aria-label", data.label); } if (data.hidden !== undefined) { el.setAttribute("aria-hidden", String(data.hidden)); } if (data.busy !== undefined) { el.setAttribute("aria-busy", String(data.busy)); } } // if (didSetRole) { // const role = el.getAttribute("role"); // if (role) { // el.setAttribute("tabindex", "0"); // } else { // el.removeAttribute("tabindex"); // } // } } } /** Moves keyboard focus to the accessible element representing the given object. */ focus(obj) { const el = this.treeElements.get(obj); if (el) { // if (!el.hasAttribute("tabindex")) { // el.setAttribute("tabindex", "0"); // } el.focus(); } } /** Removes keyboard focus from the accessible element representing the given object. */ unfocus(obj) { const el = this.treeElements.get(obj); if (el) { el.blur(); } } /** * Announces a hover event to screen readers via the ARIA live region. * @param obj - The hovered object (used to look up its label if `text` is not provided). * @param text - Optional text to announce. Falls back to the element's `aria-label`. */ hover(obj, text) { const el = this.treeElements.get(obj); // Update the live region text — screen reader announces this without stealing focus this.liveRegion.textContent = text || el?.getAttribute("aria-label") || ""; } /** Removes the accessible DOM element for the given object and stops tracking it. */ removeElement(obj) { const el = this.treeElements.get(obj); el?.remove(); this.treeElements.delete(obj); } set liveRegionMode(mode) { this.liveRegion.setAttribute("aria-live", mode); } } //# sourceMappingURL=engine_accessibility.js.map