Webcomponents: accessibility considerations: states and behaviors

Created on 14 Sep 2016  Â·  28Comments  Â·  Source: WICG/webcomponents

The problem

Currently, there is no way to replicate certain accessible properties of HTML elements with custom elements. The following are examples of things that cannot be replicated that this proposal is hoping to fix:

  • Default tabindex attribute
  • Default role attribute
  • Default aria-* attributes
  • Focusability (think of input)
  • Disableability (think of button)
  • Non‐pointer clickability (think of pressing the space bar with a button focused)

The solution: the accessibility property

The customElements.define options object could have an accessibility property, containing an “accessibility object”.

Default attributes

The “accessibility object” would contain a default property. The default property would take an object whose keys are attribute names, and values are default values for those attributes. Valid attribute names are tabindex, role and aria-*. That is, consider the following:

window.customElements.define("my-button", MyButton,
{
    accessibility:
    {
        default:
        {
            tabindex: "1",
            role: "button",
            "aria-enabled": "true"
        }
    }
});

The code makes my-button act more like a button.

States and behaviors

The “accessibility object” would contain a behaviors property. The behaviors property would take an object whose property names indicate the name of behaviors. Each “behavior property” would take an object whose property names indicate the names of states that that behavior exposes. Each “state property” would take an “accessibility object”. That is, consider the following code:

window.customElements.define("my-button", MyButton,
{
    accessibility:
    {
        default:
        {
            role: "button"
        }
        behaviors:
        {
            disableable:
            {
                enabled:
                {
                    default:
                    {
                        "aria-enabled": "true"
                    }
                    behaviors:
                    {
                        clickable:
                        {
                        }
                    }
                }
                disabled:
                {
                    default:
                    {
                        "aria-enabled": "false"
                    }
                }
            }
        }
    }
});

Here, the behavior is disableable. The states it exposes are “enabled” and “disabled”. Whenever the element is in the “enabled” state, it also uses the “clickable” behavior.

The built‐in behaviors would be the following:

  • disableable

    • enabled

    • disabled

  • focusable

    • focused

    • unfocused

  • clickable (extends focusable)

    • focused

    • unfocused

  • checkable

    • checked

    • unchecked

    • indeterminate

  • checkbox (extends checkable)

    • checked

    • unchecked

    • indeterminate

  • radio (extends checkable)

    • checked

    • unchecked

    • indeterminate

Custom states and behaviors

Built‐in states and behaviors should be reproducible by custom, user‐created states and behaviors.

Creating a custom behavior would be done by calling customElements.defineBehavior. Consider the following code:

customElements.defineBehavior("my-disableable",
{
    states:
    {
        disabled:
        {
            "matches": "[disabled]",
            details:
            {
                default:
                {
                    "aria-disabled": "true"
                }
            }
        },
        enabled:
        {
            "matches": ":not([disabled])",
            details:
            {
                default:
                {
                    "aria-disabled": "false"
                }
            }
        }
    }
}

customElements.defineBehavior("my-focusable",
{
    default:
    {
        "tabindex": "1"
    }

    states:
    {
        focused:
        {
            "matches": ":focus"
        },
        unfocused:
        {
            "matches": ":not(:focus)"
        }
    }
});

customElements.defineBehavior("my-clickable",
{
    extends: {"my-focusable": {}},
    constructor()
    {
        this.element.addEventListener("keyup", ()=>
        {
            let code = event.code;
            if(code === "Space" || code === "Enter" || code === "NumpadEnter")
            {
                this.element.click();
            }
        });
    }
});

The options object for defineBehavior would take a “behavior options object”.

The “behavior options object”’s default property would act similarly to the “accessibility object”’s default property, except that it would affect the element the behavior is applied to.

The “behavior options object”’s states property would take an object whose keys are state names, each states object’s keys’ corresponding object’s matches key would take a CSS selector. Whenever an element that uses the behavior matches that selector, the state is applied to the element. Each states object’s keys’ corresponding object’s details property would take a “behavior options object”.

The “behavior options object”’s extends property would take an object. Each key of this object would be the name of a pre‐existing behavior. Each key of each of this object’s values would be the name of a property that that pre‐existing behavior exposes; each corresponding value would take a “behavior options object”.

Any of a constructor, an connectedCallback, an atrributeChangedCallback or a disconnectedCallback property of the “behavior options object” would take a function that’d be called immediately after (or immediately before) the corresponding life‐cycle callback function of the element the behavior is applied to. Within these functions, the this object would be a special “behavior object”. This “behavior object” exposes an element property (returns the element the behavior is associated with), and a setState(string, boolean|null) method, which sets a state for that behavior to true or false; null would mean “use the defined matches string for that state”.

As a complete example, here the definition of a behavior that would make an element act like a button:

window.customElements.defineBehavior("button-like",
{
    extends:
    {
        disableable:
        {
            enabled:
            {
                extends:
                {
                    clickable: {}
                }
            }
        }
    },
    default:
    {
        role: "button"
    }
    constructor()
    {
        let element = this.element;
        element.addEventListener("click", event=>
        {
            if(element.disabled)
            {
                event.preventDefault();
                event.stopPropagation();
            }
        });
    }
});

Details

It’s important to note that each object passed to customElements.defineBehavior and window.customElements.define would be evaluated once and converted into a “flat object”. That is, an object that has no getter and whose all properties are also flat objects.

CSS pseudo‐classes

A thing that would be unreproducible from built‐in behaviors would be CSS pseudo‐classes. That is, if an element has the disableable built‐in behavior, it can be targeted by the :disabled and :enabled pseudo‐class. Such behavior would not be reproducible by a my‐disableable behaviors.

An exception would be focusable, which would merely set the default value of tabindex to 1, meaning that the :focus pseudo‐class can be very easily reproduced by custom behaviors.

However, it would be possible to select custom states from CSS by using the :state() functional pseudo‐class. The syntax would be either state(state) which would match the state state from any behavior, or state(behavior:state) which would match only the state state from the behavior behavior.

With this, in HTML, we’d have :disabled being the equivalent to :state(disableable|disabled), :enabled being the equivalent to :state(disableable|enabled), and etc.

Why?

I think this would be great to allow people to create elements that behave very similarly to (or the same as) built‐in elements. It would, besides making the platform feel more regular, also provide authors with a powerful tool to help them write more accessible web applications and documents.


Any ideas are welcome!

custom-elements

Most helpful comment

To be clear, global string-indexed registries are an antipattern that we should avoid when they're not necessary. They are necessary for custom HTML element names, because of the interaction with the parser and with CSS. But they're certainly not necessary for behaviors of the type you describe; normal JavaScript composition via lexically-scoped and imported functions is a much better solution.

All 28 comments

I prefer the solution given by https://github.com/a11y-api/a11y-api/, which allows manipulation of the actual primitive involved (the accessibility tree) without tying the solution to custom elements or creating a bunch of new concepts like behaviors with declarative matches and defaults.

@domenic

Sorry, but I can’t seem to understand this “a11y-api” you linked. How would you, say, create an element that acts like a button? With my suggestion, that would be trivial. You’d just have to write customElements.define("my-button", {behaviors:{"button-like":{}}}) with the very short “button-like” behavior defined in the OP.

You would do this.accessibleNode.role = "button" in the constructor.

@domenic

Would this cover all the behavior from a button element (i.e. focusability, disableability, the ability to be clicked with the space‐bar/enter), all without sprouting new attributes? What if someone wants to create their own behavior? Behaviors, besides accessibility, would allow people to do things such as adding methods/properties to an element, adding event listeners and firing events, etc.

To create your own behavior, you can use the normal compositional techniques in JavaScript, i.e. functions. Do addAwesomeBehavior(this) in your constructor.

To be clear, global string-indexed registries are an antipattern that we should avoid when they're not necessary. They are necessary for custom HTML element names, because of the interaction with the parser and with CSS. But they're certainly not necessary for behaviors of the type you describe; normal JavaScript composition via lexically-scoped and imported functions is a much better solution.

@domenic

To create your own behavior, you can use the normal compositional techniques in JavaScript, i.e. functions. Do addAwesomeBehavior(this) in your constructor.

Behaviors would allow you to listen to attribute changes very easily, besides allowing you to set states and add default attributes to elements.

To be clear, global string-indexed registries are an antipattern that we should avoid when they're not necessary. [
] Normal JavaScript composition via lexically-scoped and imported functions is a much better solution.

It’s important to note that functions are “global string‐indexed registries”. The have a named (a string index) and are declared (registered) on the Javascript top‐level (global).

They are necessary for custom HTML element names, because of the interaction with the parser and with CSS.

It’s also interesting to note that behaviors would interact with CSS as well.

Also, I might have been a bit brief here, assuming that you could grok my entire thought process from that link. Let me expand a bit.

I totally agree with the overall goal of making custom elements able to behave the same way as built-ins. You may have seen my earlier work on that in https://github.com/domenic/html-as-custom-elements which has some thoughts on accessibility as well.

However, I think it's important to get there in the way that makes the most sense, which I think (per the extensible web manifesto) is to expose the same low-level APIs, like the accessibility tree, that browsers use to create the built-ins. This can then be imperatively manipulated by JavaScript code (similar to the C++ backing the built-ins).

We already have one declarative way of achieving these accessibility characteristics and the related behaviors: the is attribute. It has its own restrictions due to its declarative nature. We should not build another declarative way, with new restrictions (e.g. being restricted to CSS selector matching). Instead we should go imperative and allow full freedom.

That said, there might be room for some very basic declarative APIs to cover the 80% case. E.g. I think @dominiccooney has a proposal that simply allows setting the default role and tabIndex. This gives you the ability to emulate some of the built-ins, but not all (e.g. the a element changes its role depending on the presence of the href attribute). I'm not sure whether that's worth it compared to just using the is attribute. But maybe it's reasonable as a stopgap until we are ready to expose the accessibility tree.

Functions are not a global string indexed registry, because you can declare them in different scopes besides the global.

The _global scope_ is a string-indexed registry, and that's precisely why it's an antipattern to use global variables.

We already have one declarative way of achieving these accessibility characteristics and the related behaviors: the is attribute.

Agreed, it already covers everything mentioned in here, without the risk that some piece of accessibility is forgotten.

The reference link on how to create a custom button that actually is a button:
http://www.w3.org/TR/custom-elements/#custom-elements-customized-builtin-example

@domenic

If you’ve read my entire proposal, you’d see that I suggest an imperative way to manipulate states as well:

Any of a constructor, a connectedCallback, an atrributeChangedCallback or a disconnectedCallback property of the “behavior options object” would take a function that’d be called immediately after (or immediately before) the corresponding life‐cycle callback function of the element the behavior is applied to. Within these functions, the this object would be a special “behavior object”. This “behavior object” exposes an element property (returns the element the behavior is associated with), and a setState(string, boolean|null) method, which sets a state for that behavior to true or false; null would mean “use the defined matches string for that state”.

That is, I could create a toggleable behavior like this:

customElements.defineBehavior("my-toggleable", class {},
{
    constructor()
    {
        let t = this;
        t.active = false;
        t.element.addEventListener("click", ()=>
        {
            t.setState("active", t.active = !t.active);
        });
    }
    active:
    {
        matches: ":not(*)"
    }
});

@WebReflection

The idea is to allow custom elements to act as built‐in elements without depending on them. That is, most built‐in elements would be reproducible by using built‐in behaviors, and all built‐in behaviors would be reproducible by custom behaviors.

This would make the platform feel much more regular, by making built‐in elements feel like they are made with the same features available to authors.

That is, most built‐in elements would be reproducible by using built‐in behaviors, and all built‐in behaviors would be reproducible by custom behaviors.

I say “most”, because, of course, you wouldn’t be able to reproduce elements like head or style, nor replaced elements like img.

neither tr, the script itself ... you name it. I understand your idea but I don't think it'll help the Web to be more accessible in the long run.

Having accessibility implicitly inherited seems a better way to move the Web forward, and we should use is as much as we can for graceful enhancement and backward compatibility.

Adding yet another way to obtain a real button looks like something maybe considerable in a possible, future, V2, keeping is where it is now in specs.

Having accessibility implicitly inherited seems a better way to move the Web forward, and we should use is as much as we can for graceful enhancement and backward compatibility.

It’s important to note that not every browser agree with is. I personally think it feels quirky and is not the best solution we could have. I think these behaviors are a much more general solution. We could allow the accessibilities to be inherited from other elements, so that when you extend from HTMLButtonElement, even if you don’t get something that is _styled_ like a button is, you do get something that _behaves_ like a button. That is, you could very easily get a button‐like element by inheriting behavior from the actual button element:

customElements.define("my-button", class extends HTMLButtonElement {}); // inherits behaviors and default attribute values

that's not quite the way it works when you extends and it will fail in those browsers that allow creating HTMLElement extends only.

In any case, every modern, old, and nightly browser (beside FF nightly which is definitively not ready for CE v1) works already as it should thanks to this polyfill:
https://github.com/WebReflection/document-register-element#document-register-element-

Native extends have been used in production for years now via v0.
Dropping them, beside agreeing or not how simple or quirky these are, will likely break the web.

If vendors (just one for what I know) will keep ignoring current V1 specs, such vendor will be polyfilled so I don't see the problem.

@WebReflection

I’m not saying we should “drop” custom elements v0 immediately. It should be supported as a deprecated feature until authors have finished transitioning into the newest specifications.

I am saying we should not drop the v0 extends behavior which has been adopted for valid reasons and used in production already.

I've often read some spec is driven by libraries and adoption, v0 is a clear example we should not ignore. V1 is more ES6 friendly, but less real-world useful if it won't have is in it, like it is already accordingly with current specifications that have been already polyfilled and adopted.

P.S. I'd like to underline that not only my polyfill always followed specs and implemented extends but even webcomponents.js and Polymer has used this feature:
https://github.com/webcomponents/webcomponentsjs/blob/v1-polymer-edits/src/CustomElements/v1/CustomElements.js#L577-L581
I'm not sure what kind of extra evidence we need to put an end to this dispute about is: it's already shipped, with or without 100% consensus.

Developers used and needed it. I don't have more to add to this discussion so I'll stop here. I hope my points are clear (or at least my point of view).

@WebReflection

Well, I can’t say I have a lot of experience with it, but I can’t see is being much more useful than the inherited accessibility property I described above. It seems to do a very similar thing, yet, it is much more regular, or as I think you’d put it, “graceful” than is is.

I think I understand your point of view, but I think it’s flawed. It’s not simply because people like and use a feature that it is the best option. Nor it’s because it’s convenient. We have to think language‐design‐wise. How regular/scalable is that feature? How will having that feature positively/negatively impact the language in the future? I can have a very convenient feature that people will use that is not scalable at all.


@domenic

The global scope is a string-indexed registry, and that's precisely why it's an antipattern to use global variables.

Are you saying I should use myLibrary.fun instead of just fun? How is that different from myLibrary_fun? Ultimately, functions _are_ string‐indexed in the top‐level. What if another library with the same name as mine decides to use the same “myLibrary” object to store its stuff? How is doing myLibrary.fun different from doing my_library-my_behavior?

I don't really feel like this is the appropriate place for me to educate about global vs. local scopes, so I'll withdraw from this discussion.

@domenic

That’s really rude. What you’re doing is indirectly calling me an idiot (or at least ignorant). What I’m suggesting is that people, in the lack of true namespaceability (“local scopes”), people could emulate namespaces by appending their namespace name at the beggining of their behaviors, very similarly to how I think they should do with custom elements.

Either way, since no‐one seems to actually like this, I can see no reason to leave it open. Sorry to bother you with my with my idea, I just tought I could be helpful for once.

@Zambonifofex I think it's like <button onclick="globalScope()"> VS the ability to define a specific callback/listener within a private/local scope instead of exposing it globally so that no conflicts, like you said, could possibly happen.

It is also kinda off topic at this point so you've done the right thing closing it.

@WebReflection

I think it's like <button onclick="globalScope()"> VS the ability to define a specific callback/listener within a private/local scope instead of exposing it globally so that no conflicts, like you said, could possibly happen.

I see. However, behaviors would be a very shareable thing. I think it would have been rare to actually need a non‐global behavior.

It is also kinda off topic at this point so you've done the right thing closing it.

I’m just kinda sad since I spent a lot of time designing this feature for it to be closed in less than five hours. I was really expecting to get more positive feedback.

I think that I closing the thread was more of a result of my disapointment than an actual thoughtful action. I’m really unsure whether I should leave this closed or if I should reopen it. All I really want is to hear feedback from more than two people.

If anyone who might have been lurking/following this thread may not mind sharing their opinions, it’d be extremely appreciated.

Given the lack of (what to me are) convincing arguments against this feature, and to gather more feedback, I decided that I should reopen this.

Keep in mind that this is not a sudden decision; closing this thread was. I’ve been considering if I should reopen it for the last couple hours, and I still really believe that this will benifit the Web in the long run.

Adding the ability to specify the default role of a custom element seems like a sensible feature to have. Having to build your own accessibility tree just when simply setting the role would do the work is not a great developer ergonomics.

But we should probably start from the baby steps and just add defaultRole property to the list of options to customElements.define. That'll solve the majority of simple use cases.

I think if we do anything here it needs to be tightly coupled with the work going on over at https://github.com/WICG/aom (as @domenic also pointed out). That's already on its way being implemented, so anything that doesn't directly build on that is a non-starter I think. So based on that I'm going to close this, but I hope that doesn't stop @Zambonifofex from contributing in the future. I recommend reaching out a little sooner next time with your problem so you don't put a lot of effort into something others are working on too.

Reading https://whatwg.org/working-mode and https://whatwg.org/faq#adding-new-features might help with future contributions.

@annevk, I should probably investigate the repo you’ve linked to at some point.

I hope that doesn't stop @zambonifofex from contributing in the future.

I’ll be definitely contributing through suggestions in the future whenever I can (and whenever I have an idea), but right now I have kinda temporarily abandoned Web development to do other stuff. Additionally, this year I have an unrelated project I’ll be working on, so I don’t think I’ll be able to contribute too much.

Either way, I wish you all the best, and I hope you guys have success pursuing whatever you have planned.

Indeed the latest discussion appears to be heading the direction of exposing ARIA properties on ShadowRoot so that we should allow rules to be specified & ARIA values to be overridden by components nicely.

Was this page helpful?
0 / 5 - 0 ratings