Skip to content

Commit

Permalink
References: Support for ID Ref List
Browse files Browse the repository at this point in the history
While it borrows from [targets][] and [outlets][], the idea of
`references` also draws inspiration from the WAI ARIA concept of an [ID
Ref and ID Ref List][aria-ref] attribute, like:

* [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)

Providing built-in support from Stimulus for elements that a controller
establishes an [`[id]`-based relationship][id-relationship] with through
ARIA attributes could cultivate a virtuous cycle between assistive
technologies (reliant on semantics and document-hierarchy driven
relationships) and client-side feature development (reliant on
low-friction DOM traversal and state change callbacks).

[targets]: https://stimulus.hotwired.dev/reference/targets
[outlets]: https://stimulus.hotwired.dev/reference/outlets
[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
  • Loading branch information
seanpdoyle committed Dec 17, 2022
1 parent 2ff5440 commit da0f125
Show file tree
Hide file tree
Showing 12 changed files with 658 additions and 5 deletions.
2 changes: 1 addition & 1 deletion docs/reference/css_classes.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
permalink: /reference/css-classes.html
order: 06
order: 07
---

# CSS Classes
Expand Down
141 changes: 141 additions & 0 deletions docs/reference/references.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
---
permalink: /reference/references.html
order: 05
---

# References

_References_ 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.

[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

<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, a **reference** can be located **anywhere on the page**.


## Definitions

A Controller class can define a `static references` array to declare which of
its element's attribute names to use to resolve its references.

By default, a Controller's `static references` property is defined to include a
list of [ARIA ID reference and ID reference list attributes][aria-ref] to
establish [`[id]`-based relationships][id-relationship] out-of-the-box,
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)


Define attribute names in your controller class using the `static references` array:

<meta data-controller="callout" data-callout-text-value='static references'>
<meta data-controller="callout" data-callout-text-value='"aria-controls"'>


```js
// disclosure_controller.js

export default class extends Controller {
static references = [ "aria-controls" ]

toggle() {
const expanded = this.element.getAttribute("aria-expanded")

for (const ariaControlsReference of this.ariaControlsReferences) {
ariaControlsReference.hidden = expanded != "true"
}
}
}
```

## Properties

For each attribute name defined in the `static references` array, Stimulus adds three properties to your controller, where `[name]` corresponds to an attribute's name:

| Kind | Property name | Return Type | Effect
| ---- | ------------- | ----------- | -----------
| Existential | `has[Name]Reference` | `Boolean` | Tests for presence of an element with `[id="${name}"]`
| Singular | `[name]Reference` | `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]References` | `Array<Element>` | Returns all `Element`s whose `[id]` values are included in the `[name]` attribute's tokens

Kebab-case attribute names will be transformed to camelCase and TitleCase. For example, `aria-controls` will transform into `ariaControls` and `AriaControls`.

## Reference Callbacks

Reference 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]ReferenceConnected()` or `[name]ReferenceDisconnected()`.

<meta data-controller="callout" data-callout-text-value='"aria-activedescendant"'>
<meta data-controller="callout" data-callout-text-value="ariaActivedescendantReferenceConnected(element)">
<meta data-controller="callout" data-callout-text-value="ariaActivedescendantReferenceDisconnected(element)">

```js
// combobox_controller.js

export default class extends Controller {
static references = [ "aria-activedescendant" ]
static target = [ "selected" ]

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

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

### References are Assumed to be Present

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

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

### Optional references

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

```js
if (this.hasAriaControlsReference) {
this.safelyCallSomethingOnTheReference(this.ariaControlsReference)
}
```

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

```js
for (const ariaControlsReference of this.ariaControlsReferences) {
this.safelyCallSomethingOnTheReference(this.ariaControlsReference)
}
```
2 changes: 1 addition & 1 deletion docs/reference/using_typescript.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
permalink: /reference/using-typescript.html
order: 07
order: 08
---

# Using Typescript
Expand Down
27 changes: 25 additions & 2 deletions src/core/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,21 @@ import { Scope } from "./scope"
import { ValueObserver } from "./value_observer"
import { TargetObserver, TargetObserverDelegate } from "./target_observer"
import { OutletObserver, OutletObserverDelegate } from "./outlet_observer"
import { namespaceCamelize } from "./string_helpers"
import { ReferenceObserver, ReferenceObserverDelegate } from "./reference_observer"
import { camelize, namespaceCamelize } from "./string_helpers"
import { readInheritableStaticArrayValues } from "./inheritable_statics"

export class Context implements ErrorHandler, TargetObserverDelegate, OutletObserverDelegate {
export class Context
implements ErrorHandler, ReferenceObserverDelegate, TargetObserverDelegate, OutletObserverDelegate
{
readonly module: Module
readonly scope: Scope
readonly controller: Controller
private bindingObserver: BindingObserver
private valueObserver: ValueObserver
private targetObserver: TargetObserver
private outletObserver: OutletObserver
private referenceObserver: ReferenceObserver

constructor(module: Module, scope: Scope) {
this.module = module
Expand All @@ -28,6 +33,12 @@ export class Context implements ErrorHandler, TargetObserverDelegate, OutletObse
this.valueObserver = new ValueObserver(this, this.controller)
this.targetObserver = new TargetObserver(this, this)
this.outletObserver = new OutletObserver(this, this)
this.referenceObserver = new ReferenceObserver(
this.element,
document,
readInheritableStaticArrayValues(module.controllerConstructor, "references"),
this
)

try {
this.controller.initialize()
Expand All @@ -42,6 +53,7 @@ export class Context implements ErrorHandler, TargetObserverDelegate, OutletObse
this.valueObserver.start()
this.targetObserver.start()
this.outletObserver.start()
this.referenceObserver.start()

try {
this.controller.connect()
Expand All @@ -63,6 +75,7 @@ export class Context implements ErrorHandler, TargetObserverDelegate, OutletObse
this.handleError(error, "disconnecting controller")
}

this.referenceObserver.stop()
this.outletObserver.stop()
this.targetObserver.stop()
this.valueObserver.stop()
Expand Down Expand Up @@ -129,6 +142,16 @@ export class Context implements ErrorHandler, TargetObserverDelegate, OutletObse
this.invokeControllerMethod(`${namespaceCamelize(name)}OutletDisconnected`, outlet, element)
}

// Reference observer delegate

referenceConnected(element: Element, attributeName: string) {
this.invokeControllerMethod(`${camelize(attributeName)}ReferenceConnected`, element)
}

referenceDisconnected(element: Element, attributeName: string) {
this.invokeControllerMethod(`${camelize(attributeName)}ReferenceDisconnected`, element)
}

// Private

invokeControllerMethod(methodName: string, ...args: any[]) {
Expand Down
17 changes: 17 additions & 0 deletions src/core/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Application } from "./application"
import { ClassPropertiesBlessing } from "./class_properties"
import { Constructor } from "./constructor"
import { Context } from "./context"
import { ReferencePropertiesBlessing } from "./reference_properties"
import { OutletPropertiesBlessing } from "./outlet_properties"
import { TargetPropertiesBlessing } from "./target_properties"
import { ValuePropertiesBlessing, ValueDefinitionMap } from "./value_properties"
Expand All @@ -14,7 +15,19 @@ export class Controller<ElementType extends Element = Element> {
TargetPropertiesBlessing,
ValuePropertiesBlessing,
OutletPropertiesBlessing,
ReferencePropertiesBlessing,
]
static references: string[] = [
"aria-activedescendant",
"aria-details",
"aria-errormessage",
"aria-controls",
"aria-describedby",
"aria-flowto",
"aria-labelledby",
"aria-owns",
]

static targets: string[] = []
static outlets: string[] = []
static values: ValueDefinitionMap = {}
Expand Down Expand Up @@ -57,6 +70,10 @@ export class Controller<ElementType extends Element = Element> {
return this.scope.outlets
}

get references() {
return this.scope.references
}

get classes() {
return this.scope.classes
}
Expand Down
122 changes: 122 additions & 0 deletions src/core/reference_observer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { Multimap } from "../multimap"
import { ElementObserver, ElementObserverDelegate } from "../mutation-observers/element_observer"
import {
Token,
TokenListObserver,
TokenListObserverDelegate,
parseTokenString,
} from "../mutation-observers/token_list_observer"

export interface ReferenceObserverDelegate {
referenceConnected(element: Element, attributeName: string): void
referenceDisconnected(element: Element, attributeName: string): void
}

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

constructor(element: Element, root: Document, attributeNames: string[], delegate: ReferenceObserverDelegate) {
this.delegate = delegate
this.element = element
this.root = root
this.attributeNames = attributeNames

this.elementObserver = new ElementObserver(root.body, this)
for (const attributeName of attributeNames) {
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) {
for (const attributeName of this.attributeNames) {
const tokens = this.element.getAttribute(attributeName) || ""
for (const token of parseTokenString(tokens, this.element, attributeName)) {
if (token.content == element.id) this.connectReference(element, attributeName)
}
}
}

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

elementAttributeChanged() {}

// Token list observer delegate

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

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

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

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

private connectReference(element: Element, attributeName: string) {
if (!this.elementsByAttributeName.has(attributeName, element)) {
this.elementsByAttributeName.add(attributeName, element)
this.delegate.referenceConnected(element, attributeName)
}
}

private disconnectReference(element: Element, attributeName: string) {
if (this.elementsByAttributeName.has(attributeName, element)) {
this.elementsByAttributeName.delete(attributeName, element)
this.delegate.referenceDisconnected(element, attributeName)
}
}

private disconnectAllElements() {
for (const attributeName of this.elementsByAttributeName.keys) {
for (const element of this.elementsByAttributeName.getValuesForKey(attributeName)) {
this.disconnectReference(element, attributeName)
}
}
}
}
Loading

0 comments on commit da0f125

Please sign in to comment.