-
Notifications
You must be signed in to change notification settings - Fork 3k
Description
Edit: Explainer Draft
Problem statement
Many HTML attributes need to reference one or more HTML elements in the document. This includes:
- Popovers (
popovertarget) - Invokers (
invoketarget) - A host of ARIA attributes (e.g.
aria-describedby,aria-labelledby,aria-activedescendant,aria-controls,aria-details,aria-flowto,aria-ownsetc.) for, in<label>and<output>listin<input>- the new
anchorattribute
It is also a frequent use case in author web components as well, with WC authors coming up with wildly inconsistent solutions because they are forced to choose between ergonomics and consistency with the web platform. Most copy HTML as a source of truth and good design decisions, which makes the problem worse.
Currently, the only way to specify such references is to give these elements ids (if they don't already have them) and using these ids to link to them in these attributes.
This imposes high friction (especially when not using tooling to generate HTML), as authors then need to come up with suitable ids for elements that wouldn't otherwise have one and manually do the linking (and as we know, naming things is one of the hardest things in Computer Science 😁 ). It also introduces error conditions, as it is a common authoring mistake to change an id and forgetting to change the id references to it, pasting a chunk of HTML and forgetting to edit all the references, or ending up with broken references due to accidental duplicate ids.
This is a very common author pain point, and authors are pretty vocal about it: DX-related complaints were the 3rd biggest a11y complaint in the preliminary State of HTML results. It especially hurts a11y, since the effects of broken references in the a11y tree are not always obvious, and the more friction it takes to make HTML accessible, the less likely authors are to do it. And while for <label> this is somewhat mitigated by the option to make the association implicitly by nesting the form control within the <label>, for the other cases there is no similar option.
Being able to link to elements in a way that is relative to the element the attribute is specified on would solve all of these issues, and make writing ARIA much more pleasant.
Some considerations are:
- The migration path for authors. It would introduce an undesirable cliff, if using a relative reference suddenly requires changing all of their existing absolute references. The more substantial the edit required, the sharper the cliff.
- Not all use cases require relative references, so ideally the syntax should allow mixing the two. While a11y-related use cases tend to primarily need relative references, attributes like
listneed both (e.g. a "country" field would need to autocomplete to the same list of countries everywhere).
Relative reference use cases
More research is needed here, but in my experience most relative use cases are pretty simple paths from the current element to the one being referenced. Things like:
- Go N levels up (usually 1-2) and get the first element with a class of
foo - Get the next/previous element
Proposed solutions
There are two components to coming up with a solution:
- Syntax: How to specify elements relative to the current element (i.e. the one the attribute is specified on)?
- Disambiguation: Assuming we don't want to add new attributes for this, how to disambiguate this syntax from the id references currently in use?
1. Disambiguation
In theory IDs can contain any character, though in practice they very rarely contain characters going beyond CSS idents. So how do we come up with a syntax that minimizes conflicts with ids used in the wild? There are two main categories here.
1a. Syntactic switch
This approach allows mixing absolute and relative references even on the same element by using a syntactic switch to say "this is not an id, it's a relative reference".
It would require a fair bit of web compat research to flesh out the details (I can reach out to the HTTP Archive folks), but the main paths here are:
- Imposing restrictions on the relative syntax so that it needs to contain certain characters that are very unlikely to appear in IDs, e.g. that the value needs to begin with
&or:. Ids can still be specified by escaping these characters.
Example:
<label for="& + *">Foo:</label>
<input name="foo" />- A functional syntax that wraps the relative reference (e.g.
selector(),path(),relative(),ref()etc.). This is more verbose, but has the added benefit of clarity and extensibility. If plain names likeselector()are not web-compatible, we could go the route of URL fragments and prepend these functions with a certain symbol to further minimize the odds of collision.
Example:
<label for="selector(+ *)">Foo:</label>
<input name="foo" />1b. Scoping attribute
Instead of a special syntax, this would introduce an additional attribute that switches how references work on an entire subtree.
Ideally, the attribute is not just an opt-in, but also adds value, e.g. by specifying the scope of matching so that references can be simplified. Scopes can be nested, and the parent scope is matched if the closest scope did not yield any results. The syntax of individual attributes need to provide ways to escape the scope, for the use cases where global matching is genuinely desirable.
A big downside of this approach is that because it affects references across a whole subtree, it makes migration more painful, unless we do weird things like "match as an id first, and if that doesn't match anything, try something different", which can be unpredictable and error-prone.
Example:
<li idscope>
<label for="foo">Foo:</label>
<input id="foo" />
</li>
<li idscope>
<label for="foo">Foo:</label>
<input id="foo" />
</li>2. Syntax
I see two avenues here:
- CSS selectors, potentially with severe restrictions, especially at first.
- Identifiers that an already be used on more than one element (e.g.
classorname)
An attribute to restrict scope (see 1b) would be useful for both, but while it is a convenience for 1, it is essential for 2 to be useful.
While a custom microsyntax might be tempting, I would advise against it (we even have a TAG principle in the works advising against custom microsyntaxes).
2a. CSS selectors
CSS has recently introduced relative selectors that start with a combinator and/or can use :scope or & to represent the current element (see 1 2).
If relative selectors could be allowed in these attributes, authors could do things like + .description or .description:has(+ &) etc. If the selector specified matches multiple elements, the first one will be used unless the attribute expects multiple elements.
Not the entirety of CSS selectors needs to be allowed.
In fact, I think an MVP could be as small as just <id-selector> | [<combinator>? [ <type-selector> | '*']? <class-selector>* ]+ (see below wrt combinators).
In syntaxes that involve a scoping attribute (see 1b), <id-selector> could still match globally, providing an escape hatch from the scoping.
Pros:
- Power. CSS selectors are an incredibly powerful querying language and new developments in CSS selectors will automatically make this syntax more powerful as well. Even if only a very restricted subset ships, it would be very easy to progressively enable add CSS selector syntax to the allowlist.
- Familiarity: CSS selectors are a syntax authors are already familiar with.
- Potential to solve more than ergonomics: there are many discussions about cross-root ARIA, as well as discussions about re-introducing some form of a Shadow DOM combinator. Something like this could be a piece of that puzzle.
The main downside seems to be performance. Having to specify "the previous element" with a :has(+ &) that searches the entire DOM tree is likely unacceptable. I would need to check with the rest of the CSS WG, but I suspect that if the WHATWG is interested in pursuing this, we might be open to exploring combinators that go backwards (previous sibling, parent) to facilitate common cases without :has(). Things like - for "previous sibling" and < for parent have been proposed before and add value to authors more generally as well. I suspect this would only be tenable if these kinds of backwards combinators are a possibility and/or combined with an attribute to limit scope. (edit: CSS WG issue filed)
2b. Identifiers
Even something as restricted as being able to specify an identifier and a root for the query would address the vast majority of use cases. So basically all we need is a way to express a non-unique identifier and a scoping attribute to mark the root of the matching.
Candidates for this could be:
- Class names
nameattributeitempropattributepropertyattribute
This produces a very concise, readable syntax for common cases, maintains the same syntax for the individual attributes, and removes the disambiguation problem. However, it is unclear whether a single hierarchy of scopes would be sufficient to cover use cases, and makes it harder to interpret individual values.
There are two strategies here, each with its own tradeoffs.
Only look at non-unique identifiers in subtrees with the scoping attribute
This means authors would have to opt-in to this kind of matching, by using a scoping attribute (e.g. namescope, or even something that specifies the identifier being scoped e.g. refscope="name").
This is more predictable, but makes migration costly. To enable referencing elements globally, the scoping attribute would need to be added to the root element as well, which makes it even harder to mix and match id references with relative references.
Match as id first, fall back to non-unique identifier if no element found with said id
This ensures that copying a chunk of HTML within another does not break, but it means that references can break by simply adding an id in another place in the document, which can be very hard to debug. There is also no precedent in the web platform where the same identifier can mean either id or something else (especially with different scopes!).
3. What if we could fix this without any new syntax?
This is a stretch, but might be worth exploring.
name is an existing attribute that identifies an element similarly to an id, but does not have the restriction of uniqueness.
The algorithm for resolving references could be redefined as:
- First, look for an element with that
id. If found, return that. - Let scopingRoot = referencing element
- While scopingRoot != document
- Look for an element with a
nameequal to the identifier provided inside scopingRoot. - If found, return it.
- Otherwise, let scopingRoot = parent of scopingRoot
- Look for an element with a
I wonder how web-compatible something like that would be. Since it would only make a difference if the reference is broken to begin with, maybe it's not too unrealistic?
I’m still unsure what the best solution is, but I’m leaning towards 3 if it's web-compatible, or 1a + 2a if not, perhaps with an optional scoping attribute.
Rationale:
- Attribute values are self-contained, and can be interpreted by simply looking at the attribute value
- It maintains the ability to copy-paste fragments of HTML without breakage
- Allows mixing absolute and relative references, avoiding cliffs
If there is consensus to pursue this, I could do the research of exploring what syntax could be web-compatible.