Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions packages/core/docs/accessibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Accessibility API

OpenTUI provides accessibility support for screen readers via cross-platform text-to-speech.

## Quick Start

```typescript
import { BoxRenderable, getAccessibilityManager } from "@opentui/core"

// Enable accessibility
const accessibility = getAccessibilityManager()
accessibility.setEnabled(true)

// Create accessible component
const button = new BoxRenderable(renderer, {
accessibilityRole: "button",
accessibilityLabel: "Submit Form",
accessibilityHint: "Press Enter to submit",
})

// Announcements
accessibility.announce("Form submitted successfully", "polite")
```

## Accessibility Properties

| Property | Type | Description |
| --------------------- | ------------------- | ----------------------------------------- |
| `accessibilityRole` | `AccessibilityRole` | Semantic role (button, text, input, etc.) |
| `accessibilityLabel` | `string` | Human-readable name for screen readers |
| `accessibilityValue` | `string \| number` | Current value (for inputs, sliders) |
| `accessibilityHint` | `string` | Additional context about the element |
| `accessibilityHidden` | `boolean` | Hide from assistive technologies |
| `accessibilityLive` | `AccessibilityLive` | Live region update behavior |

## Roles

```typescript
type AccessibilityRole =
| "none"
| "button"
| "text"
| "input"
| "checkbox"
| "radio"
| "list"
| "listItem"
| "menu"
| "menuItem"
| "dialog"
| "alert"
| "progressbar"
| "slider"
| "scrollbar"
| "group"
```

## Live Regions

Control how dynamic content changes are announced:

- `"off"` - Don't announce changes
- `"polite"` - Announce when idle
- `"assertive"` - Announce immediately

## AccessibilityManager

```typescript
const manager = getAccessibilityManager()

// Enable/disable
manager.setEnabled(true)
manager.enabled // boolean

// Announcements
manager.announce("Message", "polite" | "assertive")

// Focus tracking
manager.setFocused(renderable) // Announces element name + role

// Events
manager.on("accessibility-event", (event) => {
console.log(event.type, event.targetId)
})
```

## Platform Support

| Platform | TTS Method | Screen Reader |
| -------- | ---------- | -------------- |
| Linux | spd-say | Orca |
| Windows | SAPI | NVDA, Narrator |
| macOS | say | VoiceOver |

### Linux Requirements

- `speech-dispatcher` installed
- `espeak-ng` for text-to-speech
- Configure: `DefaultModule espeak-ng` in `/etc/speech-dispatcher/speechd.conf`

### Windows Requirements

- PowerShell (included with Windows)
- System.Speech assembly (included with .NET Framework)

### macOS Requirements

- None - `say` is built into macOS

## Example

See `examples/accessibility-demo.ts` for a complete demonstration.
121 changes: 120 additions & 1 deletion packages/core/src/Renderable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,35 @@ export enum RenderableEvents {
BLURRED = "blurred",
}

export type AccessibilityRole =
| "none"
| "button"
| "text"
| "input"
| "checkbox"
| "radio"
| "list"
| "listItem"
| "menu"
| "menuItem"
| "dialog"
| "alert"
| "progressbar"
| "slider"
| "scrollbar"
| "group"

export type AccessibilityLive = "off" | "polite" | "assertive"

export interface AccessibilityOptions {
accessibilityRole?: AccessibilityRole
accessibilityLabel?: string
accessibilityValue?: string | number
accessibilityHint?: string
accessibilityHidden?: boolean
accessibilityLive?: AccessibilityLive
}

export interface Position {
top?: number | "auto" | `${number}%`
right?: number | "auto" | `${number}%`
Expand Down Expand Up @@ -91,7 +120,9 @@ export interface LayoutOptions extends BaseRenderableOptions {
enableLayout?: boolean
}

export interface RenderableOptions<T extends BaseRenderable = BaseRenderable> extends Partial<LayoutOptions> {
export interface RenderableOptions<T extends BaseRenderable = BaseRenderable>
extends Partial<LayoutOptions>,
AccessibilityOptions {
width?: number | "auto" | `${number}%`
height?: number | "auto" | `${number}%`
zIndex?: number
Expand Down Expand Up @@ -235,6 +266,14 @@ export abstract class Renderable extends BaseRenderable {
protected _opacity: number = 1.0
private _flexShrink: number = 1

// Accessibility properties
protected _accessibilityRole: AccessibilityRole = "none"
protected _accessibilityLabel: string | undefined = undefined
protected _accessibilityValue: string | number | undefined = undefined
protected _accessibilityHint: string | undefined = undefined
protected _accessibilityHidden: boolean = false
protected _accessibilityLive: AccessibilityLive = "off"

private renderableMapById: Map<string, Renderable> = new Map()
protected _childrenInLayoutOrder: Renderable[] = []
protected _childrenInZIndexOrder: Renderable[] = []
Expand Down Expand Up @@ -278,6 +317,14 @@ export abstract class Renderable extends BaseRenderable {
this._liveCount = this._live && this._visible ? 1 : 0
this._opacity = options.opacity !== undefined ? Math.max(0, Math.min(1, options.opacity)) : 1.0

// Initialize accessibility options
this._accessibilityRole = options.accessibilityRole ?? "none"
this._accessibilityLabel = options.accessibilityLabel
this._accessibilityValue = options.accessibilityValue
this._accessibilityHint = options.accessibilityHint
this._accessibilityHidden = options.accessibilityHidden ?? false
this._accessibilityLive = options.accessibilityLive ?? "off"

// TODO: use a global yoga config
this.yogaNode = Yoga.Node.create(yogaConfig)
this.yogaNode.setDisplay(this._visible ? Display.Flex : Display.None)
Expand Down Expand Up @@ -352,6 +399,78 @@ export abstract class Renderable extends BaseRenderable {
}
}

// Accessibility getters and setters
public get accessibilityRole(): AccessibilityRole {
return this._accessibilityRole
}

public set accessibilityRole(value: AccessibilityRole) {
if (this._accessibilityRole !== value) {
this._accessibilityRole = value
this.onAccessibilityChange()
}
}

public get accessibilityLabel(): string | undefined {
return this._accessibilityLabel
}

public set accessibilityLabel(value: string | undefined) {
if (this._accessibilityLabel !== value) {
this._accessibilityLabel = value
this.onAccessibilityChange()
}
}

public get accessibilityValue(): string | number | undefined {
return this._accessibilityValue
}

public set accessibilityValue(value: string | number | undefined) {
if (this._accessibilityValue !== value) {
this._accessibilityValue = value
this.onAccessibilityChange()
}
}

public get accessibilityHint(): string | undefined {
return this._accessibilityHint
}

public set accessibilityHint(value: string | undefined) {
if (this._accessibilityHint !== value) {
this._accessibilityHint = value
this.onAccessibilityChange()
}
}

public get accessibilityHidden(): boolean {
return this._accessibilityHidden
}

public set accessibilityHidden(value: boolean) {
if (this._accessibilityHidden !== value) {
this._accessibilityHidden = value
this.onAccessibilityChange()
}
}

public get accessibilityLive(): AccessibilityLive {
return this._accessibilityLive
}

public set accessibilityLive(value: AccessibilityLive) {
if (this._accessibilityLive !== value) {
this._accessibilityLive = value
this.onAccessibilityChange()
}
}

protected onAccessibilityChange(): void {
// Hook for subclasses and future accessibility manager integration
// Will be used to notify platform accessibility APIs of changes
}

public hasSelection(): boolean {
return false
}
Expand Down
Loading
Loading