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

Conversation

seanpdoyle
Copy link
Contributor

@seanpdoyle seanpdoyle commented Dec 17, 2022

While it borrows from targets and outlets, the idea of
ariaElements also draws inspiration from the The ARIAMixin
introduced in the ARIA 1.3 specification and the [WAI ARIA concept of an
ID Ref and ID Ref List attribute, like:

Providing built-in support from Stimulus for elements that a controller
establishes an [id]-based 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).

From the Documentation for ARIA Elements:


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 and Stimulus Outlets, but provide access regardless of where they occur in the document.

<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
that establish [id]-based
relationships
, including:

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 Elements 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 of the Accessible Rich Internet Applications (WAI-ARIA) 1.3 Specification.

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().

// 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:

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:

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

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

for (const ariaControlsElement of this.ariaControlsElements) {
  this.safelyCallSomethingOnTheElement(this.ariaControlsElement)
}

@seanpdoyle
Copy link
Contributor Author

@marcoroth I'd love some feedback on the idea and how it might co-exist with Outlets.

@adrienpoly I'd love some feedback on how it fits into the idea of attribute change callbacks, as it takes one step past listening for [aria-controls] (or other) attribute changes to also include fetching the relevant elements.

@marcoroth
Copy link
Member

marcoroth commented Jan 28, 2023

Hey @seanpdoyle, thanks for exploring this idea!

I like the concept of having an API which allows to target any reference on the page. The thing I'm wondering about is if we should introduce this as a "Stimulus API using a static property" or if we should provide some kind of "helper" that you can access via something like this.aria. So you could access the aria-controls via something like this.aria.controlsReferences or this.aria.controls.

Since the static references defaults to a few aria-* attributes already there is almost never a need to declare the static property yourself, unless you want to target any element with an ID on the page (which wouldn't use any ARIA-related attributes). But that somehow feels like a different use-case to me.

Referencing any element on a page

So let's say we want to target a <nav> element which has a navigation ID attached to it (<nav id="navigation">...</nav>) in our controller.

Then we would do <div data-controller="layout" data-navigation="nav"> and in the controller:

static references = ["data-navigation"]

Which would then allow use the reference the element as this.dataNavigationReference, which somehow feels a little bit weird to me. Also because the attribute which is being used for the reference doesn't use the data-[identifier]-reference attribute format, which is kinda what you would expect from such a Stimulus API.

"Elements API" ?

I'm wondering if we should introduce something like a more general "Elements API" which more or less follows the Targets API, but with the difference that you can target any element on the page.

static elements = ["navigation"]

Defining the reference to the element on the controller element

<nav id="nav">...</nav>
<!-- ... -->
<div data-controller="layout" data-layout-navigation-element="nav"></div>

and then allows us to reference this.navigationElement in the controller.

Defining the reference anywhere in the DOM

We could also do it the other way around and say:

<nav data-layout-element="navigation">...</nav>
<!-- ... -->
<div data-controller="layout"></div>

Conclusion

Personally I don't think an "ARIA helper" and something like an Elements API would contradict each other. Or how do you feel about this? Do you think this is one and the same concern and should be handled in the same API?

@rik
Copy link
Contributor

rik commented Jan 28, 2023

Specifically for ARIA, ARIA 1.3 adds attributes to access referenced elements in ARIAMixin in the form ariaFooElements.

The first examples eventually becomes:

export default class extends Controller {
  toggle() {
    const expanded = this.element.ariaExpanded

    for (const ariaControlsElement of this.element.ariaControlsElements) {
      ariaControlsElement.hidden = expanded != "true"
    }
  }
}

These attributes are also setters so you can do:

element.ariaControlsElements = [otherElement]

This is not implemented in shipping browsers but from a quick test, it is in Safari Technology Preview 162 and Chrome Canary 111.

I'm sharing this to help design the API with upcoming standard changes in mind.

@seanpdoyle seanpdoyle force-pushed the aria-callbacks branch 3 times, most recently from f97694b to 6605c4d Compare January 29, 2023 04:48
@seanpdoyle seanpdoyle changed the title References: Support for ID Ref List Aria Elements: Support for aria- prefixed Element reference attributes Jan 29, 2023
@seanpdoyle
Copy link
Contributor Author

seanpdoyle commented Jan 29, 2023

@rik thank you for sharing that document!

@marcoroth I've updated this work to reference "ARIA Elements" and "aria[name]Element" instead of "aria[name]Reference".

In the short term, we'll stick with declaring has${name}Element, ${name}Element, and ${name}Elements directly on the controller, regardless of built-in support for the properties.

This means that elements referenced by the singular aria-activedescendant will be available as a single this.ariaActiveDescendantElement and the collection of this.ariaActiveDescendantElements in the same way that this.ariaOwnsElement and this.ariaOwnsElements will behave.

In the future, as support for ARIAMixin properties (like this.element.ariaActiveDescendantElement) is rolled out, we can optimized the AriaElementSet class to access those directly when they're available, then fall back to the current implementation when they're not yet supported by the user's browser.

@seanpdoyle
Copy link
Contributor Author

The thing I'm wondering about is if we should introduce this as a "Stimulus API using a static property" or if we should provide some kind of "helper" that you can access via something like this.aria. So you could access the aria-controls via something like this.aria.controlsReferences or this.aria.controls.

@marcoroth defining the aria-prefixed properties (like ariaControlsElement) on the controller felt like the best balance of Stimulus conventions and adherence to the interfaces laid out by the upcoming ARIAMixin.

Controllers already define singular- and collection-returning properties (suffixed by Target and Targets), so defining Element and Elements suffixed properties felt like the best way to reinforce that pattern while providing a collection-returning property for singular properties (in this case, HTMLElement.ariaActiveDescendantElement) and a singular-returning property for collection-returning properties (for example, ariaLabelledByElement for the HTMLElement. ariaLabelledByElements property).

Some day, controller code will be able to choose the property that is most appropriate, without making substantiate property name changes (for example, they'll be able to choose between this.ariaControlsElement, this.ariaControlsElements, or this.element.ariaControlsElements).

The callback naming (...ElementConnected and ...ElementDisconnected) was another motivation. It matches the Target Callback naming convention and maps directly to the Controller's property name. How do you imagine callbacks being named for an intermediate this.aria object?

@marcoroth
Copy link
Member

@seanpdoyle I completely agree that defining the aria prefixed properties on the controller is the best approach for a Stimulus API.

The names you proposed for the accessors, has[name]Element, [name]Element, [name]Elements and callbacks [name]ElementConnected / [name]ElementDisconnected are spot on. I also really like that they match the names of the ARIA mixin.

I think the thing I was thrown off by was that these properties just exist without declaring them compared to what we have in other Stimulus APIs, which is what made me believe that this should use something like a this.aria helper. And also that they weren't using the convention of data-[identifier]-[name]-element. But I guess that suggestion also wouldn't have been really Stimulus-like.

I know that the first version of your PR used a static property, even if it wasn't really user-facing since it had the defaults set already, unless you did actually override them.

I guess what I was alluding to is that there might be an opportunity here for a general "Elements API" which incorporates ARIA, data and other attributes for element lookups in a single API.

I was wondering if we could take this one step further by (re-)introducing a static elements property that requires developers to explicitly define the elements they want to reference. This way, we also don't have to maintain a default list of supported ARIA attributes and they "don't just magically appear in your controller".

For aria-* attributes, we can use the regular aria-* attribute directly and fall back to the data-[identifier]-[name]-element convention if it's not present. And for data-* attributes, the same approach could be taken if it makes sense.

So the end result would look something like this:

Static Property Singular Element Accessor Attribute used for ID lookup
static elements = [
"aria-controls"
]
this.ariaControlsElement aria-controls

(which falls back to data-[identifier]-aria-controls-element if the aria-controls attribute is not present)
 
static elements = [
"data-navigation"
]
this.dataNavigationElement data-navigation

(which falls back to data-[identifier]-data-navigation-element if the data-navigation attribute is not present)
 
static elements = [
"navigation"
]
this.navigationElement data-[identifier]-navigation-element

Maybe the second case above for the data-* attribute is weird. But nevertheless, I think this approach would provide the best ergonomics/expectations and would align with other APIs we have. Does that make sense?

@rik
Copy link
Contributor

rik commented Feb 1, 2023

Having the data-[identifier]-aria-controls-element fallback feels counter-productive to encourage more accessible apps: those attributes would not be interpreted by assistive technologies.

This way, we also don't have to maintain a default list of supported ARIA attributes and they "don't just magically appear in your controller".

To me, having ARIA attributes magically appear is a strength of the proposal. This is Stimulus knowing about web standards and providing added functionality on top of them.

@marcoroth
Copy link
Member

marcoroth commented Feb 1, 2023

We can also leave out the data-[identifier]-aria-controls-element concept if that doesn't make sense.

I agree that it shouldn't "encourage" people to make applications "less accessible" by default. It was more of an idea to make the existing Stimulus conventions work with this concept. But it's fine to me, if we can agree that ARIA attributes are "special enough" to treat them differently by just allowing the "short version".

To me, having ARIA attributes magically appear is a strength of the proposal.

Maybe it's just me, but I feel like I can grasp Stimulus controllers better and quicker if I can glance at the the top of the class and can see what the controllers "depends" on and what possible things it can have.

Having to declare the elements which could be used in the a controller does not only benefit that, but also helps with performance, since it doesn't need to register Mutation Observers and properties for every possible element that can be used. Because every Stimulus controller instance would otherwise get at least 8 more Mutation Observers for just covering elements which aren't even used.

@seanpdoyle
Copy link
Contributor Author

Maybe it's just me, but I feel like I can grasp Stimulus controllers better and quicker if I can glance at the the top of the class and can see what the controllers "depends" on and what possible things it can have.

Unfortunately, not all aria--prefixed attributes have directly camelCased properties like aria-controls to ariaControls. Properties like ariaDescribedBy and ariaActiveDescendant need special handling.

We're already documenting this mapping, but we wouldn't want to burden callers with declarations that get the mapping correct.

Would the static elements be an array of attribute names, with the mapping done behind the scenes?

@marcoroth
Copy link
Member

Unfortunately, not all aria--prefixed attributes have directly camelCased properties like aria-controls to ariaControls. Properties like ariaDescribedBy and ariaActiveDescendant need special handling.

We're already documenting this mapping, but we wouldn't want to burden callers with declarations that get the mapping correct.

I have to admit, I missed that detail until now, sorry about that! I remember looking at that table and wondering why it's there. That makes sense now.

Would the static elements be an array of attribute names, with the mapping done behind the scenes?

Yeah, I guess we can handle that mapping behind the scenes. And maybe even that [aria-describedby] and [aria-described-by] would both end up in ariaDescribedBy. Or we show a warning that [aria-described-by] should be [aria-describedby].

@sfnelson
Copy link

sfnelson commented Feb 1, 2023

@seanpdoyle thanks for your work on this – in my opinion it's a breath of fresh air to see Stimulus embracing web standards.

To me, having ARIA attributes magically appear is a strength of the proposal.

I strongly agree with this statement, I think it demonstrates a good separation of concerns – the role/behaviour of the element is documented, vs coupling it to the controller's internal behaviour. Controllers serve the dom, not the other way around.

I strongly prefer this convention over configuration approach compared to the verbose and pedantic syntax required for outlets and targets (especially for namespaced controllers). I'd like to see more examples of Stimulus embracing existing dom functionality.

@seb-jean
Copy link
Contributor

Do you think this PR can be merged?

While it borrows from [targets][] and [outlets][], the idea of
`ariaElements` also draws inspiration from the The [ARIAMixin][]
introduced in the ARIA 1.3 specification and 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

[ARIAMixin]: https://w3c.github.io/aria/#ARIAMixin
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

5 participants