From 1ad8b4f515e4a9197a3533fc161f3ebb4d3699a6 Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Sat, 28 Jan 2023 08:17:21 -0500 Subject: [PATCH] Rename from References to ARIA Elements Following the roadmap laid out by mentions of [ARIAMixin][] in the ARIA 1.3 specification, rename the underlying domain terms from References to ARIA Elements [ARIAMixin]: https://w3c.github.io/aria/#ARIAMixin --- docs/reference/aria_elements.md | 129 ++++++++++++++ docs/reference/references.md | 141 --------------- src/core/aria.ts | 25 +++ ...e_observer.ts => aria_element_observer.ts} | 51 +++--- src/core/aria_element_properties.ts | 41 +++++ .../{reference_set.ts => aria_element_set.ts} | 17 +- src/core/context.ts | 31 ++-- src/core/controller.ts | 18 +- src/core/reference_properties.ts | 40 ----- src/core/scope.ts | 4 +- src/tests/controllers/aria_controller.ts | 14 +- src/tests/modules/core/aria_tests.ts | 168 +++++++++--------- 12 files changed, 343 insertions(+), 336 deletions(-) create mode 100644 docs/reference/aria_elements.md delete mode 100644 docs/reference/references.md create mode 100644 src/core/aria.ts rename src/core/{reference_observer.ts => aria_element_observer.ts} (58%) create mode 100644 src/core/aria_element_properties.ts rename src/core/{reference_set.ts => aria_element_set.ts} (70%) delete mode 100644 src/core/reference_properties.ts diff --git a/docs/reference/aria_elements.md b/docs/reference/aria_elements.md new file mode 100644 index 00000000..d8f790e7 --- /dev/null +++ b/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. + + + + + +```html + + +... + + +``` + +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` | 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()`. + + + + +```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) +} +``` diff --git a/docs/reference/references.md b/docs/reference/references.md deleted file mode 100644 index 38bad1cb..00000000 --- a/docs/reference/references.md +++ /dev/null @@ -1,141 +0,0 @@ ---- -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 - - - - - -```html - - -... - - -``` - -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: - - - - - -```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` | 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()`. - - - - - -```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) -} -``` diff --git a/src/core/aria.ts b/src/core/aria.ts new file mode 100644 index 00000000..37bbb218 --- /dev/null +++ b/src/core/aria.ts @@ -0,0 +1,25 @@ +type ValueOf = 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 + +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) + } +} diff --git a/src/core/reference_observer.ts b/src/core/aria_element_observer.ts similarity index 58% rename from src/core/reference_observer.ts rename to src/core/aria_element_observer.ts index 27bfc09f..1b638b6d 100644 --- a/src/core/reference_observer.ts +++ b/src/core/aria_element_observer.ts @@ -6,31 +6,30 @@ import { TokenListObserverDelegate, parseTokenString, } from "../mutation-observers/token_list_observer" +import { AriaAttributeName, AriaPropertyName, ariaMapping, isAriaAttributeName, forEachAriaMapping } from "./aria" -export interface ReferenceObserverDelegate { - referenceConnected(element: Element, attributeName: string): void - referenceDisconnected(element: Element, attributeName: string): void +export interface AriaElementObserverDelegate { + ariaElementConnected(element: Element, attributeName: AriaAttributeName, propertyName: AriaPropertyName): void + ariaElementDisconnected(element: Element, attributeName: AriaAttributeName, propertyName: AriaPropertyName): void } -export class ReferenceObserver implements ElementObserverDelegate, TokenListObserverDelegate { - readonly delegate: ReferenceObserverDelegate +export class AriaElementObserver implements ElementObserverDelegate, TokenListObserverDelegate { + readonly delegate: AriaElementObserverDelegate readonly element: Element readonly root: Document - readonly attributeNames: string[] readonly elementObserver: ElementObserver readonly tokenListObservers: TokenListObserver[] = [] - readonly elementsByAttributeName = new Multimap() + readonly elementsByAttributeName = new Multimap() - constructor(element: Element, root: Document, attributeNames: string[], delegate: ReferenceObserverDelegate) { + constructor(element: Element, root: Document, delegate: AriaElementObserverDelegate) { this.delegate = delegate this.element = element this.root = root - this.attributeNames = attributeNames this.elementObserver = new ElementObserver(root.body, this) - for (const attributeName of attributeNames) { + forEachAriaMapping((attributeName) => { this.tokenListObservers.push(new TokenListObserver(element, attributeName, this)) - } + }) } start() { @@ -61,21 +60,21 @@ export class ReferenceObserver implements ElementObserverDelegate, TokenListObse } elementMatched(element: Element) { - for (const attributeName of this.attributeNames) { + forEachAriaMapping((attributeName) => { const tokens = this.element.getAttribute(attributeName) || "" for (const token of parseTokenString(tokens, this.element, attributeName)) { - if (token.content == element.id) this.connectReference(element, attributeName) + if (token.content == element.id) this.connectAriaElement(element, attributeName) } - } + }) } elementUnmatched(element: Element) { - for (const attributeName of this.attributeNames) { + forEachAriaMapping((attributeName, propertyName) => { const tokens = this.element.getAttribute(attributeName) || "" for (const token of parseTokenString(tokens, this.element, attributeName)) { - if (token.content == element.id) this.disconnectReference(element, attributeName) + if (token.content == element.id) this.disconnectAriaElement(element, attributeName, propertyName) } - } + }) } elementAttributeChanged() {} @@ -83,39 +82,39 @@ export class ReferenceObserver implements ElementObserverDelegate, TokenListObse // Token list observer delegate tokenMatched({ element, attributeName, content }: Token) { - if (element == this.element && this.attributeNames.includes(attributeName)) { + if (element == this.element && isAriaAttributeName(attributeName)) { const relatedElement = this.root.getElementById(content) - if (relatedElement) this.connectReference(relatedElement, attributeName) + if (relatedElement) this.connectAriaElement(relatedElement, attributeName) } } tokenUnmatched({ element, attributeName, content }: Token) { - if (element == this.element && this.attributeNames.includes(attributeName)) { + if (element == this.element && isAriaAttributeName(attributeName)) { const relatedElement = this.root.getElementById(content) - if (relatedElement) this.disconnectReference(relatedElement, attributeName) + if (relatedElement) this.disconnectAriaElement(relatedElement, attributeName, ariaMapping[attributeName]) } } - private connectReference(element: Element, attributeName: string) { + private connectAriaElement(element: Element, attributeName: AriaAttributeName) { if (!this.elementsByAttributeName.has(attributeName, element)) { this.elementsByAttributeName.add(attributeName, element) - this.delegate.referenceConnected(element, attributeName) + this.delegate.ariaElementConnected(element, attributeName, ariaMapping[attributeName]) } } - private disconnectReference(element: Element, attributeName: string) { + private disconnectAriaElement(element: Element, attributeName: AriaAttributeName, propertyName: AriaPropertyName) { if (this.elementsByAttributeName.has(attributeName, element)) { this.elementsByAttributeName.delete(attributeName, element) - this.delegate.referenceDisconnected(element, attributeName) + this.delegate.ariaElementDisconnected(element, attributeName, propertyName) } } private disconnectAllElements() { for (const attributeName of this.elementsByAttributeName.keys) { for (const element of this.elementsByAttributeName.getValuesForKey(attributeName)) { - this.disconnectReference(element, attributeName) + this.disconnectAriaElement(element, attributeName, ariaMapping[attributeName]) } } } diff --git a/src/core/aria_element_properties.ts b/src/core/aria_element_properties.ts new file mode 100644 index 00000000..b000aca3 --- /dev/null +++ b/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(_constructor: Constructor) { + 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) + }, + }, + } +} diff --git a/src/core/reference_set.ts b/src/core/aria_element_set.ts similarity index 70% rename from src/core/reference_set.ts rename to src/core/aria_element_set.ts index 80868ef2..a5485c30 100644 --- a/src/core/reference_set.ts +++ b/src/core/aria_element_set.ts @@ -1,6 +1,7 @@ import { Scope } from "./scope" +import { AriaAttributeName } from "./aria" -export class ReferenceSet { +export class AriaElementSet { readonly root: NonElementParentNode readonly scope: Scope @@ -9,31 +10,31 @@ export class ReferenceSet { this.scope = scope } - has(attributeName: string) { + has(attributeName: AriaAttributeName) { return this.find(attributeName) != null } - find(...attributeNames: string[]) { + find(...attributeNames: AriaAttributeName[]) { return attributeNames.reduce( (element, attributeName) => element || this.findElement(attributeName), - undefined as Element | undefined + null as Element | null ) } - findAll(...attributeNames: string[]) { + findAll(...attributeNames: AriaAttributeName[]) { return attributeNames.reduce( (elements, attributeName) => [...elements, ...this.findAllElements(attributeName)], [] as Element[] ) } - private findElement(attributeName: string) { + private findElement(attributeName: AriaAttributeName) { const [id] = splitTokens(this.scope.element.getAttribute(attributeName)) - return this.root.getElementById(id) || undefined + return this.root.getElementById(id) || null } - private findAllElements(attributeName: string): Element[] { + private findAllElements(attributeName: AriaAttributeName): Element[] { const elements: Element[] = [] for (const id of splitTokens(this.scope.element.getAttribute(attributeName))) { diff --git a/src/core/context.ts b/src/core/context.ts index 136ad38e..7a7dbedf 100644 --- a/src/core/context.ts +++ b/src/core/context.ts @@ -9,12 +9,12 @@ import { Scope } from "./scope" import { ValueObserver } from "./value_observer" import { TargetObserver, TargetObserverDelegate } from "./target_observer" import { OutletObserver, OutletObserverDelegate } from "./outlet_observer" -import { ReferenceObserver, ReferenceObserverDelegate } from "./reference_observer" -import { camelize, namespaceCamelize } from "./string_helpers" -import { readInheritableStaticArrayValues } from "./inheritable_statics" +import { AriaElementObserver, AriaElementObserverDelegate } from "./aria_element_observer" +import { AriaAttributeName, AriaPropertyName } from "./aria" +import { namespaceCamelize } from "./string_helpers" export class Context - implements ErrorHandler, ReferenceObserverDelegate, TargetObserverDelegate, OutletObserverDelegate + implements ErrorHandler, AriaElementObserverDelegate, TargetObserverDelegate, OutletObserverDelegate { readonly module: Module readonly scope: Scope @@ -23,7 +23,7 @@ export class Context private valueObserver: ValueObserver private targetObserver: TargetObserver private outletObserver: OutletObserver - private referenceObserver: ReferenceObserver + private ariaElementObserver: AriaElementObserver constructor(module: Module, scope: Scope) { this.module = module @@ -33,12 +33,7 @@ export class Context 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 - ) + this.ariaElementObserver = new AriaElementObserver(this.element, document, this) try { this.controller.initialize() @@ -53,7 +48,7 @@ export class Context this.valueObserver.start() this.targetObserver.start() this.outletObserver.start() - this.referenceObserver.start() + this.ariaElementObserver.start() try { this.controller.connect() @@ -75,7 +70,7 @@ export class Context this.handleError(error, "disconnecting controller") } - this.referenceObserver.stop() + this.ariaElementObserver.stop() this.outletObserver.stop() this.targetObserver.stop() this.valueObserver.stop() @@ -142,14 +137,14 @@ export class Context this.invokeControllerMethod(`${namespaceCamelize(name)}OutletDisconnected`, outlet, element) } - // Reference observer delegate + // Aria Element observer delegate - referenceConnected(element: Element, attributeName: string) { - this.invokeControllerMethod(`${camelize(attributeName)}ReferenceConnected`, element) + ariaElementConnected(element: Element, attributeName: AriaAttributeName, propertyName: AriaPropertyName) { + this.invokeControllerMethod(`${propertyName}ElementConnected`, element) } - referenceDisconnected(element: Element, attributeName: string) { - this.invokeControllerMethod(`${camelize(attributeName)}ReferenceDisconnected`, element) + ariaElementDisconnected(element: Element, attributeName: AriaAttributeName, propertyName: AriaPropertyName) { + this.invokeControllerMethod(`${propertyName}ElementDisconnected`, element) } // Private diff --git a/src/core/controller.ts b/src/core/controller.ts index 0cb0d1ab..7cc6b4f4 100644 --- a/src/core/controller.ts +++ b/src/core/controller.ts @@ -2,7 +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 { AriaElementPropertiesBlessing } from "./aria_element_properties" import { OutletPropertiesBlessing } from "./outlet_properties" import { TargetPropertiesBlessing } from "./target_properties" import { ValuePropertiesBlessing, ValueDefinitionMap } from "./value_properties" @@ -23,17 +23,7 @@ export class Controller { TargetPropertiesBlessing, ValuePropertiesBlessing, OutletPropertiesBlessing, - ReferencePropertiesBlessing, - ] - static references: string[] = [ - "aria-activedescendant", - "aria-details", - "aria-errormessage", - "aria-controls", - "aria-describedby", - "aria-flowto", - "aria-labelledby", - "aria-owns", + AriaElementPropertiesBlessing, ] static targets: string[] = [] @@ -78,8 +68,8 @@ export class Controller { return this.scope.outlets } - get references() { - return this.scope.references + get ariaElements() { + return this.scope.ariaElements } get classes() { diff --git a/src/core/reference_properties.ts b/src/core/reference_properties.ts deleted file mode 100644 index 7974bbf5..00000000 --- a/src/core/reference_properties.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Controller } from "./controller" -import { Constructor } from "./constructor" -import { readInheritableStaticArrayValues } from "./inheritable_statics" -import { camelize, capitalize } from "./string_helpers" - -export function ReferencePropertiesBlessing(constructor: Constructor) { - const references = readInheritableStaticArrayValues(constructor, "references") - return references.reduce((properties, referenceDefinition) => { - return Object.assign(properties, propertiesForReferenceDefinition(referenceDefinition)) - }, {} as PropertyDescriptorMap) -} - -function propertiesForReferenceDefinition(attributeName: string) { - const name = camelize(attributeName) - - return { - [`${name}Reference`]: { - get(this: Controller) { - const element = this.references.find(attributeName) - if (element) { - return element - } else { - throw new Error(`Missing element referenced by "[${attributeName}]" for "${this.identifier}" controller`) - } - }, - }, - - [`${name}References`]: { - get(this: Controller) { - return this.references.findAll(attributeName) - }, - }, - - [`has${capitalize(name)}Reference`]: { - get(this: Controller) { - return this.references.has(attributeName) - }, - }, - } -} diff --git a/src/core/scope.ts b/src/core/scope.ts index 9463c5a4..e05bcea4 100644 --- a/src/core/scope.ts +++ b/src/core/scope.ts @@ -6,7 +6,7 @@ import { Schema } from "./schema" import { attributeValueContainsToken } from "./selectors" import { TargetSet } from "./target_set" import { OutletSet } from "./outlet_set" -import { ReferenceSet } from "./reference_set" +import { AriaElementSet } from "./aria_element_set" export class Scope { readonly schema: Schema @@ -14,7 +14,7 @@ export class Scope { readonly identifier: string readonly guide: Guide readonly outlets: OutletSet - readonly references = new ReferenceSet(document, this) + readonly ariaElements = new AriaElementSet(document, this) readonly targets = new TargetSet(this) readonly classes = new ClassMap(this) readonly data = new DataMap(this) diff --git a/src/tests/controllers/aria_controller.ts b/src/tests/controllers/aria_controller.ts index 65ae886d..1de3dd0c 100644 --- a/src/tests/controllers/aria_controller.ts +++ b/src/tests/controllers/aria_controller.ts @@ -5,15 +5,23 @@ export class AriaController extends Controller { added = new Multimap() removed = new Multimap() - ariaControlsReferenceConnected(element: Element) { + ariaControlsElementConnected(element: Element) { this.added.add("aria-controls", element) } - ariaControlsReferenceDisconnected(element: Element) { + ariaDescribedByElementConnected(element: Element) { + this.added.add("aria-describedby", element) + } + + ariaControlsElementDisconnected(element: Element) { this.removed.add("aria-controls", element) } - ariaOwnsReferenceConnected(element: Element) { + ariaDescribedByElementDisconnected(element: Element) { + this.removed.add("aria-describedby", element) + } + + ariaOwnsElementConnected(element: Element) { const { parentElement } = this.element if (parentElement) { diff --git a/src/tests/modules/core/aria_tests.ts b/src/tests/modules/core/aria_tests.ts index 173b305e..465cb7bf 100644 --- a/src/tests/modules/core/aria_tests.ts +++ b/src/tests/modules/core/aria_tests.ts @@ -1,6 +1,6 @@ import { ControllerTestCase } from "../../cases/controller_test_case" import { AriaController } from "../../controllers/aria_controller" -import { camelize, capitalize } from "../../../core/string_helpers" +import { capitalize } from "../../../core/string_helpers" export default class AriaTests extends ControllerTestCase(AriaController) { identifier = ["aria"] @@ -10,131 +10,114 @@ export default class AriaTests extends ControllerTestCase(AriaController) {
` - async "test defines reference properties"() { - const attributeNames = [ - "aria-activedescendant", - "aria-details", - "aria-errormessage", - "aria-controls", - "aria-describedby", - "aria-flowto", - "aria-labelledby", - "aria-owns", + async "test defines dynamic Element properties"() { + const propertyNames = [ + "ariaActiveDescendant", + "ariaDetails", + "ariaErrorMessage", + "ariaControls", + "ariaDescribedBy", + "ariaFlowTo", + "ariaLabelledBy", + "ariaOwns", ] const controllerElement = this.findElement("#controller") - controllerElement.setAttribute("data-controller", "aria") - await this.nextFrame - - for (const attributeName of attributeNames) { - const property = camelize(attributeName) + await this.setAttribute(controllerElement, "data-controller", "aria") + for (const property of propertyNames) { this.assert.ok( - `has${capitalize(property)}Reference` in this.controller, - `expected controller to define has${capitalize(property)}Reference` + `has${capitalize(property)}Element` in this.controller, + `expected controller to define has${capitalize(property)}Element` ) - this.assert.ok(`${property}Reference` in this.controller, `expected controller to define ${property}Reference`) - this.assert.ok(`${property}References` in this.controller, `expected controller to define ${property}References`) + this.assert.ok(`${property}Element` in this.controller, `expected controller to define ${property}Element`) + this.assert.ok(`${property}Elements` in this.controller, `expected controller to define ${property}Elements`) } } - async "test invokes ariaControlsReferenceConnected when setting [aria-controls] attribute"() { + async "test invokes ariaControlsElementConnected when setting [aria-controls] attribute"() { const controllerElement = this.findElement("#controller") const element = this.findElement("#a") - controllerElement.setAttribute("data-controller", "aria") - await this.nextFrame - controllerElement.setAttribute("aria-controls", "a") - await this.nextFrame + await this.setAttribute(controllerElement, "data-controller", "aria") + await this.setAttribute(controllerElement, "aria-controls", "a") this.assert.deepEqual(this.controller.added.getValuesForKey("aria-controls"), [element]) } - async "test invokes ariaControlsReferenceDisconnected when removing [aria-controls] attribute"() { + async "test invokes ariaControlsElementDisconnected when removing [aria-controls] attribute"() { const controllerElement = this.findElement("#controller") - controllerElement.setAttribute("data-controller", "aria") - controllerElement.setAttribute("aria-controls", "a") - await this.nextFrame - controllerElement.removeAttribute("aria-controls") - await this.nextFrame + await this.setAttribute(controllerElement, "data-controller", "aria") + await this.setAttribute(controllerElement, "aria-controls", "a") + await this.removeAttribute(controllerElement, "aria-controls") this.assert.deepEqual(this.controller.removed.getValuesForKey("aria-controls"), [this.findElement("#a")]) } - async "test invokes ariaControlsReferenceConnected when the controller connects"() { + async "test invokes ariaControlsElementConnected when the controller connects"() { const controllerElement = this.findElement("#controller") const element = this.findElement("#a") - controllerElement.setAttribute("aria-controls", "a") - await this.nextFrame - controllerElement.setAttribute("data-controller", "aria") - await this.nextFrame + await this.setAttribute(controllerElement, "aria-controls", "a") + await this.setAttribute(controllerElement, "data-controller", "aria") this.assert.deepEqual(this.controller.added.getValuesForKey("aria-controls"), [element]) } - async "test invokes ariaControlsReferenceDisconnected when the controller disconnects"() { + async "test invokes ariaControlsElementDisconnected when the controller disconnects"() { const controllerElement = this.findElement("#controller") - controllerElement.setAttribute("data-controller", "aria") - controllerElement.setAttribute("aria-controls", "a") - await this.nextFrame + await this.setAttribute(controllerElement, "data-controller", "aria") + await this.setAttribute(controllerElement, "aria-controls", "a") const removed = this.controller.removed - controllerElement.removeAttribute("data-controller") - await this.nextFrame + await this.removeAttribute(controllerElement, "data-controller") this.assert.deepEqual(removed.getValuesForKey("aria-controls"), [this.findElement("#a")]) } - async "test hasAriaControlsReference returns true when there is an element"() { + async "test hasAriaControlsElement returns true when there is an element"() { const controllerElement = this.findElement("#controller") - controllerElement.setAttribute("data-controller", "aria") - await this.nextFrame - controllerElement.setAttribute("aria-controls", "a") - await this.nextFrame + await this.setAttribute(controllerElement, "data-controller", "aria") + await this.setAttribute(controllerElement, "aria-controls", "a") - this.assert.ok((this.controller as any)["hasAriaControlsReference"]) + this.assert.ok((this.controller as any)["hasAriaControlsElement"]) } - async "test hasAriaControlsReference returns false when there isn't an element"() { + async "test hasAriaControlsElement returns false when there isn't an element"() { const controllerElement = this.findElement("#controller") - controllerElement.setAttribute("data-controller", "aria") - await this.nextFrame + await this.setAttribute(controllerElement, "data-controller", "aria") - this.assert.notOk((this.controller as any)["hasAriaControlsReference"]) + this.assert.notOk((this.controller as any)["hasAriaControlsElement"]) } - async "test ariaControlsReference returns the first element"() { + async "test ariaControlsElement returns the first element"() { const controllerElement = this.findElement("#controller") const element = this.findElement("#a") - controllerElement.setAttribute("data-controller", "aria") - controllerElement.setAttribute("aria-controls", "a") - await this.nextFrame + await this.setAttribute(controllerElement, "data-controller", "aria") + await this.setAttribute(controllerElement, "aria-controls", "a") - this.assert.equal((this.controller as any)["ariaControlsReference"], element) + this.assert.equal((this.controller as any)["ariaControlsElement"], element) } - async "test ariaControlsReferences returns the list of elements"() { + async "test ariaControlsElements returns the list of elements"() { const controllerElement = this.findElement("#controller") const a = this.findElement("#a") const b = this.findElement("#b") - controllerElement.setAttribute("data-controller", "aria") - controllerElement.setAttribute("aria-controls", "b c a") - await this.nextFrame + await this.setAttribute(controllerElement, "data-controller", "aria") + await this.setAttribute(controllerElement, "aria-controls", "b c a") - this.assert.deepEqual((this.controller as any)["ariaControlsReferences"], [b, a]) + this.assert.deepEqual((this.controller as any)["ariaControlsElements"], [b, a]) } - async "test invokes ariaControlsReferenceConnected when an element referenced by [aria-controls] connects"() { + async "test invokes ariaControlsElementConnected when an element referenced by [aria-controls] connects"() { const controllerElement = this.findElement("#controller") - controllerElement.setAttribute("data-controller", "aria") - controllerElement.setAttribute("aria-controls", "c") - await this.nextFrame + await this.setAttribute(controllerElement, "data-controller", "aria") + await this.setAttribute(controllerElement, "aria-controls", "c") this.fixtureElement.insertAdjacentHTML("beforeend", `
`) await this.nextFrame @@ -142,27 +125,24 @@ export default class AriaTests extends ControllerTestCase(AriaController) { this.assert.deepEqual(this.controller.added.getValuesForKey("aria-controls"), [this.findElement("#c")]) } - async "test invokes ariaControlsReferenceDisconnected when an element referenced by [aria-controls] disconnects"() { + async "test invokes ariaControlsElementDisconnected when an element referenced by [aria-controls] disconnects"() { const controllerElement = this.findElement("#controller") const c = Object.assign(document.createElement("div"), { id: "c" }) - controllerElement.setAttribute("data-controller", "aria") - controllerElement.setAttribute("aria-controls", "c") + await this.setAttribute(controllerElement, "data-controller", "aria") + await this.setAttribute(controllerElement, "aria-controls", "c") this.fixtureElement.append(c) await this.nextFrame - c.remove() - await this.nextFrame + await this.remove(c) this.assert.deepEqual(this.controller.removed.getValuesForKey("aria-controls"), [c]) } - async "test invokes ariaControlsReferenceConnected when adding [aria-controls] token"() { + async "test invokes ariaControlsElementConnected when adding [aria-controls] token"() { const controllerElement = this.findElement("#controller") - controllerElement.setAttribute("data-controller", "aria") - await this.nextFrame - controllerElement.setAttribute("aria-controls", "a b") - await this.nextFrame + await this.setAttribute(controllerElement, "data-controller", "aria") + await this.setAttribute(controllerElement, "aria-controls", "a b") this.assert.deepEqual(this.controller.added.getValuesForKey("aria-controls"), [ this.findElement("#a"), @@ -170,25 +150,45 @@ export default class AriaTests extends ControllerTestCase(AriaController) { ]) } - async "test invokes ariaControlsReferenceDisconnected when removing [aria-controls] token"() { + async "test invokes ariaDescribedByElementConnected when adding [aria-describedby] token"() { const controllerElement = this.findElement("#controller") - controllerElement.setAttribute("data-controller", "aria") - controllerElement.setAttribute("aria-controls", "b a") - await this.nextFrame - controllerElement.setAttribute("aria-controls", "b") - await this.nextFrame + await this.setAttribute(controllerElement, "data-controller", "aria") + await this.setAttribute(controllerElement, "aria-describedby", "a b") + + this.assert.deepEqual(this.controller.added.getValuesForKey("aria-describedby"), [ + this.findElement("#a"), + this.findElement("#b"), + ]) + } + + async "test invokes ariaControlsElementDisconnected when removing [aria-controls] token"() { + const controllerElement = this.findElement("#controller") + + await this.setAttribute(controllerElement, "data-controller", "aria") + await this.setAttribute(controllerElement, "aria-controls", "b a") + await this.setAttribute(controllerElement, "aria-controls", "b") this.assert.deepEqual(this.controller.removed.getValuesForKey("aria-controls"), [this.findElement("#a")]) } + async "test invokes ariaDescribedByElementDisconnected when removing [aria-describedby] token"() { + const controllerElement = this.findElement("#controller") + + await this.setAttribute(controllerElement, "data-controller", "aria") + await this.setAttribute(controllerElement, "aria-describedby", "b a") + await this.setAttribute(controllerElement, "aria-describedby", "b") + + this.assert.deepEqual(this.controller.removed.getValuesForKey("aria-describedby"), [this.findElement("#a")]) + } + async "test does not loop infinitely when a callback writes to the attribute"() { const controllerElement = this.findElement("#controller") const a = this.findElement("#a") const b = this.findElement("#b") - controllerElement.setAttribute("data-controller", "aria") - controllerElement.setAttribute("aria-owns", "a") + this.setAttribute(controllerElement, "data-controller", "aria") + this.setAttribute(controllerElement, "aria-owns", "a") await this.nextFrame this.assert.equal(controllerElement.getAttribute("aria-owns"), "controller a b")