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.

198 lines (173 loc) 7.7 kB
import { Object3D } from "three"; import type { Context } from "./engine_setup"; import { IComponent, isComponent } from "./engine_types.js"; /** Data describing the accessible semantics for a 3D object or component. */ type AccessibilityData = { /** ARIA role (e.g. `"button"`, `"img"`, `"region"`). */ role: string; /** Human-readable label announced by screen readers. */ label: string; /** When `true`, the element is hidden from the accessibility tree. */ hidden?: boolean; /** When `true`, indicates the element's content is being updated. */ busy?: boolean; } /** * 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 { private static readonly _managers: WeakMap<object, AccessibilityManager> = new WeakMap(); /** Returns the {@link AccessibilityManager} associated with the given context or component. */ static get(obj: Context | IComponent) { if (isComponent(obj)) { return this._managers.get(obj.context); } else { return this._managers.get(obj); } } constructor( private readonly 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; } private _enabled!: boolean; /** Enables or disables the accessibility overlay. When disabled, the overlay DOM is removed. */ set enabled(value: boolean) { 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); } private readonly root: HTMLElement = document.createElement("div"); private readonly liveRegion: HTMLElement = document.createElement("div"); private readonly treeElements = new WeakMap<object, HTMLElement>(); /** * 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<T extends Object3D | IComponent>(obj: T, data: Partial<AccessibilityData>) { 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<T extends Object3D | IComponent>(obj: T) { 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<T extends Object3D | IComponent>(obj: T) { 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<T extends Object3D | IComponent>(obj: T, text?: string) { 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: Object3D | IComponent) { const el = this.treeElements.get(obj); el?.remove(); this.treeElements.delete(obj); } private set liveRegionMode(mode: "polite" | "assertive") { this.liveRegion.setAttribute("aria-live", mode); } }