@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
text/typescript
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);
}
}