Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Aria Elements: Support for aria- prefixed Element reference attributes #627

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
129 changes: 129 additions & 0 deletions docs/reference/aria_elements.md
@@ -0,0 +1,129 @@
---
permalink: /reference/aria_elements.html
order: 05
---

# ARIA Elements

_ARIA Elements_ provide direct access to _elements_ within (and without!) a Controller's scope based on their `[id]` attribute's value.

They are conceptually similar to [Stimulus Targets](https://stimulus.hotwired.dev/reference/targets) and [Stimulus Outlets](https://stimulus.hotwired.dev/reference/outlets), but provide access regardless of where they occur in the document.

<meta data-controller="callout" data-callout-text-value='aria-controls="accordion"'>
<meta data-controller="callout" data-callout-text-value='id="accordion"'>


```html
<button aria-controls="accordion" aria-expanded="false"
data-controller="disclosure" data-action="click->disclosure#toggle">
Show #accordion
</button>

...

<div id="accordion" hidden>
...
</div>
```

While a **target** is a specifically marked element **within the scope** of its own controller element, an **ARIA element** can be located **anywhere on the page**.


## Definitions

Unlike Targets, support for ARIA Elements is built into all Controllers, and
doesn't require definition or additional configurations.

Out-of-the-box, Controllers provide Elements support for all [ARIA ID reference
and ID reference list attributes][aria-ref] that establish [`[id]`-based
relationships][id-relationship], including:

* [aria-activedescendant](https://www.w3.org/TR/wai-aria-1.2/#aria-activedescendant)
* [aria-controls](https://www.w3.org/TR/wai-aria-1.2/#aria-controls)
* [aria-describedby](https://www.w3.org/TR/wai-aria-1.2/#aria-describedby)
* [aria-details](https://www.w3.org/TR/wai-aria-1.2/#aria-details)
* [aria-errormessage](https://www.w3.org/TR/wai-aria-1.2/#aria-errormessage)
* [aria-flowto](https://www.w3.org/TR/wai-aria-1.2/#aria-flowto)
* [aria-labelledby](https://www.w3.org/TR/wai-aria-1.2/#aria-labelledby)
* [aria-owns](https://www.w3.org/TR/wai-aria-1.2/#aria-owns)

[aria-ref]: https://www.w3.org/TR/wai-aria-1.2/#propcharacteristic_value
[id-relationship]: https://www.w3.org/TR/wai-aria-1.2/#attrs_relationships

## Properties

For each ARIA ID reference and ID reference list attribute, Stimulus adds three properties to your controller, where `[name]` corresponds to an attribute's name:

| Kind | Property name | Return Type | Effect
| ------------- | --------------------- | ----------------- | -----------
| Existential | `has[Name]Element` | `Boolean` | Tests for presence of an element with `[id="${name}"]`
| Singular | `[name]Element` | `Element` | Returns the first `Element` whose `[id]` value is included in the `[name]` attribute's token or throws an exception if none are present
| Plural | `[name]Elements` | `Array<Element>` | Returns all `Element`s whose `[id]` values are included in the `[name]` attribute's tokens

Kebab-case attribute names are transformed to camelCase and TitleCase according
to the following rules:

| Attribute name | camelCase name | TitleCase name
| --------------------- | -------------------- | ----------
| aria-activedescendant | ariaActiveDescendant | AriaActiveDescendant
| aria-controls | ariaControls | AriaControls
| aria-describedby | ariaDescribedBy | AriaDescribedBy
| aria-details | ariaDetails | AriaDetails
| aria-errormessage | ariaErrorMessage | AriaErrorMessage
| aria-flowto | ariaFlowTo | AriaFlowTo
| aria-labelledby | ariaLabelledBy | AriaLabelledBy
| aria-owns | ariaOwns | AriaOwns

The casing rules for these names are outlined under [§ 10.1 Interface Mixin ARIAMixin](https://w3c.github.io/aria/#x10-1-interface-mixin-ariamixin) of the [Accessible Rich Internet Applications (WAI-ARIA) 1.3 Specification](https://w3c.github.io/aria/).

## ARIA Element Callbacks

ARIA Element callbacks are specially named functions called by Stimulus to let you respond to whenever a referenced element is added or removed from the document.

To observe reference changes, define a method named `[name]ElementConnected()` or `[name]ElementDisconnected()`.

<meta data-controller="callout" data-callout-text-value="ariaActiveDescendantElementConnected(element)">
<meta data-controller="callout" data-callout-text-value="ariaActiveDescendantElementDisconnected(element)">

```js
// combobox_controller.js

export default class extends Controller {
static target = [ "selected" ]

ariaActiveDescendantElementConnected(element) {
this.selectedTarget.innerHTML = element.textContent
}

ariaActiveDescendantElementDisconnected(element) {
this.selectedTarget.innerHTML = "No selection"
}
}
```

### ARIA Elements are Assumed to be Present

When you access an ARIA Element property in a Controller, you assert that at least one corresponding ARIA Element is present. If the declaration is missing and no matching element is found Stimulus will throw an exception:

```html
Missing element referenced by "[aria-controls]" for "disclosure" controller
```

### Optional ARIA Elements

If an ARIA Element is optional or you want to assert that at least one ARIA Element is present, you must first check the presence of the ARIA Element using the existential property:

```js
if (this.hasAriaControlsElement) {
this.safelyCallSomethingOnTheElement(this.ariaControlsElement)
}
```

Alternatively, looping over an empty Array of references would have the same
result:

```js
for (const ariaControlsElement of this.ariaControlsElements) {
this.safelyCallSomethingOnTheElement(this.ariaControlsElement)
}
```
2 changes: 1 addition & 1 deletion docs/reference/css_classes.md
@@ -1,6 +1,6 @@
---
permalink: /reference/css-classes.html
order: 06
order: 07
---

# CSS Classes
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/using_typescript.md
@@ -1,6 +1,6 @@
---
permalink: /reference/using-typescript.html
order: 07
order: 08
---

# Using Typescript
Expand Down
25 changes: 25 additions & 0 deletions src/core/aria.ts
@@ -0,0 +1,25 @@
type ValueOf<T> = T[keyof T]

export const ariaMapping = {
"aria-activedescendant": "ariaActiveDescendant",
"aria-details": "ariaDetails",
"aria-errormessage": "ariaErrorMessage",
"aria-controls": "ariaControls",
"aria-describedby": "ariaDescribedBy",
"aria-flowto": "ariaFlowTo",
"aria-labelledby": "ariaLabelledBy",
"aria-owns": "ariaOwns",
} as const

export type AriaAttributeName = keyof typeof ariaMapping
export type AriaPropertyName = ValueOf<typeof ariaMapping>

export function isAriaAttributeName(attributeName: string): attributeName is AriaAttributeName {
return attributeName in ariaMapping
}

export function forEachAriaMapping(callback: (attribute: AriaAttributeName, property: AriaPropertyName) => void) {
for (const [attribute, property] of Object.entries(ariaMapping)) {
callback(attribute as AriaAttributeName, property as AriaPropertyName)
}
}
121 changes: 121 additions & 0 deletions src/core/aria_element_observer.ts
@@ -0,0 +1,121 @@
import { Multimap } from "../multimap"
import { ElementObserver, ElementObserverDelegate } from "../mutation-observers/element_observer"
import {
Token,
TokenListObserver,
TokenListObserverDelegate,
parseTokenString,
} from "../mutation-observers/token_list_observer"
import { AriaAttributeName, AriaPropertyName, ariaMapping, isAriaAttributeName, forEachAriaMapping } from "./aria"

export interface AriaElementObserverDelegate {
ariaElementConnected(element: Element, attributeName: AriaAttributeName, propertyName: AriaPropertyName): void
ariaElementDisconnected(element: Element, attributeName: AriaAttributeName, propertyName: AriaPropertyName): void
}

export class AriaElementObserver implements ElementObserverDelegate, TokenListObserverDelegate {
readonly delegate: AriaElementObserverDelegate
readonly element: Element
readonly root: Document
readonly elementObserver: ElementObserver
readonly tokenListObservers: TokenListObserver[] = []
readonly elementsByAttributeName = new Multimap<AriaAttributeName, Element>()

constructor(element: Element, root: Document, delegate: AriaElementObserverDelegate) {
this.delegate = delegate
this.element = element
this.root = root

this.elementObserver = new ElementObserver(root.body, this)
forEachAriaMapping((attributeName) => {
this.tokenListObservers.push(new TokenListObserver(element, attributeName, this))
})
}

start() {
if (this.elementObserver.started) return

this.elementObserver.start()
for (const observer of this.tokenListObservers) observer.start()
}

stop() {
if (this.elementObserver.started) {
this.disconnectAllElements()
for (const observer of this.tokenListObservers) observer.stop()
this.elementObserver.stop()
}
}

// Element observer delegate

matchElement(element: Element) {
return element.hasAttribute("id")
}

matchElementsInTree(tree: Element) {
const match = this.matchElement(tree) ? [tree] : []
const matches = Array.from(tree.querySelectorAll("[id]"))
return match.concat(matches)
}

elementMatched(element: Element) {
forEachAriaMapping((attributeName) => {
const tokens = this.element.getAttribute(attributeName) || ""
for (const token of parseTokenString(tokens, this.element, attributeName)) {
if (token.content == element.id) this.connectAriaElement(element, attributeName)
}
})
}

elementUnmatched(element: Element) {
forEachAriaMapping((attributeName, propertyName) => {
const tokens = this.element.getAttribute(attributeName) || ""
for (const token of parseTokenString(tokens, this.element, attributeName)) {
if (token.content == element.id) this.disconnectAriaElement(element, attributeName, propertyName)
}
})
}

elementAttributeChanged() {}

// Token list observer delegate

tokenMatched({ element, attributeName, content }: Token) {
if (element == this.element && isAriaAttributeName(attributeName)) {
const relatedElement = this.root.getElementById(content)

if (relatedElement) this.connectAriaElement(relatedElement, attributeName)
}
}

tokenUnmatched({ element, attributeName, content }: Token) {
if (element == this.element && isAriaAttributeName(attributeName)) {
const relatedElement = this.root.getElementById(content)

if (relatedElement) this.disconnectAriaElement(relatedElement, attributeName, ariaMapping[attributeName])
}
}

private connectAriaElement(element: Element, attributeName: AriaAttributeName) {
if (!this.elementsByAttributeName.has(attributeName, element)) {
this.elementsByAttributeName.add(attributeName, element)
this.delegate.ariaElementConnected(element, attributeName, ariaMapping[attributeName])
}
}

private disconnectAriaElement(element: Element, attributeName: AriaAttributeName, propertyName: AriaPropertyName) {
if (this.elementsByAttributeName.has(attributeName, element)) {
this.elementsByAttributeName.delete(attributeName, element)
this.delegate.ariaElementDisconnected(element, attributeName, propertyName)
}
}

private disconnectAllElements() {
for (const attributeName of this.elementsByAttributeName.keys) {
for (const element of this.elementsByAttributeName.getValuesForKey(attributeName)) {
this.disconnectAriaElement(element, attributeName, ariaMapping[attributeName])
}
}
}
}
41 changes: 41 additions & 0 deletions src/core/aria_element_properties.ts
@@ -0,0 +1,41 @@
import { Controller } from "./controller"
import { Constructor } from "./constructor"
import { capitalize } from "./string_helpers"
import { AriaAttributeName, AriaPropertyName, forEachAriaMapping } from "./aria"

export function AriaElementPropertiesBlessing<T>(_constructor: Constructor<T>) {
let properties: PropertyDescriptorMap = {}

forEachAriaMapping((attributeName, propertyName) => {
properties = Object.assign(properties, propertiesForAriaElementDefinition(attributeName, propertyName))
})

return properties
}

function propertiesForAriaElementDefinition(attributeName: AriaAttributeName, name: AriaPropertyName) {
return {
[`${name}Element`]: {
get(this: Controller) {
const element = this.ariaElements.find(attributeName)
if (element) {
return element
} else {
throw new Error(`Missing element referenced by "[${attributeName}]" for "${this.identifier}" controller`)
}
},
},

[`${name}Elements`]: {
get(this: Controller) {
return this.ariaElements.findAll(attributeName)
},
},

[`has${capitalize(name)}Element`]: {
get(this: Controller) {
return this.ariaElements.has(attributeName)
},
},
}
}
54 changes: 54 additions & 0 deletions src/core/aria_element_set.ts
@@ -0,0 +1,54 @@
import { Scope } from "./scope"
import { AriaAttributeName } from "./aria"

export class AriaElementSet {
readonly root: NonElementParentNode
readonly scope: Scope

constructor(root: NonElementParentNode, scope: Scope) {
this.root = root
this.scope = scope
}

has(attributeName: AriaAttributeName) {
return this.find(attributeName) != null
}

find(...attributeNames: AriaAttributeName[]) {
return attributeNames.reduce(
(element, attributeName) => element || this.findElement(attributeName),
null as Element | null
)
}

findAll(...attributeNames: AriaAttributeName[]) {
return attributeNames.reduce(
(elements, attributeName) => [...elements, ...this.findAllElements(attributeName)],
[] as Element[]
)
}

private findElement(attributeName: AriaAttributeName) {
const [id] = splitTokens(this.scope.element.getAttribute(attributeName))

return this.root.getElementById(id) || null
}

private findAllElements(attributeName: AriaAttributeName): Element[] {
const elements: Element[] = []

for (const id of splitTokens(this.scope.element.getAttribute(attributeName))) {
const element = document.getElementById(id)

if (element) elements.push(element)
}

return elements
}
}

function splitTokens(value: string | null): string[] {
const tokens = (value || "").split(/\s+/)

return tokens.filter((token) => !!token)
}