Relative element references in HTML

Problem statement

Currently, when HTML syntax needs to reference another element, this is typically done via id references:

<input type="radio" id="foo">
<label for="foo">Foo</label>
<input list="foo">
<datalist id="foo">
...
</datalist>

See also:

This works well for top-level, global things, but less well for repeated structures and deeply nested contexts. Assigning a bunch of ids is also more flimsy, and less portable. Copying and pasting code, the quintessential code reuse method of novices, is not friendly to this method.

Not only is assigning ids an unnecessary overhead for authors, but since all ids create global variables, it even has the potential to break JS code. The more ids that are specified, the higher the overhead to avoid collisions.

Note that Web Components also often need syntax to reference other elements, and HTML does not offer a great precedent for them, since its only element reference mechanism is global ids. Since ids are not always convenient, WC authors end up inventing their own syntax, which can often be suboptimal (I have even seen attributes that specify a …child index).

Also, given ARIA’s heavy need for relationship attributes, the additional overhead of these IDREFs means that authors are more likely to skimp on accessibility.

There should be a way to associate elements based on their relationship in the DOM tree. E.g. so that a <label> could be associated with its previous sibling without said sibling requiring an id.

Solution 1: Selectors

Selectors + :scope seem like a natural fit for this, though the full power of selectors may be too much here, and not really needed. Also, for this to work, it would need to be able to look in both directions, whereas selectors only look up the tree. Perhaps a hugely cut down selector grammar involving :has() like this might work:

<relative-selector> = <element> ':has(' [ <combinator> <element> ]* ')'
<element> = [ <type-selector> | <id-selector> | ':scope' ]

(tokens not defined here are from Selectors Level 4 )

There is also the question of disambiguation: if we keep the same attributes, the new syntax needs to be invalid as an id, or sufficiently rare, for web compat. Or, alternatively, new attributes could be introduced, which would also allow for fallbacks.

Then the label example could become:

<input type="radio">
<label relfor="input:has(+ :scope)">Foo</label>

Solution 2: Relative ids

An alternative solution would be to introduce a mechanism for relative ids, which are scoped to the closest ancestor scope with an id or relative name. E.g.:

<div prop="foo">
  <input type="radio" prop="bar">
  <label for="bar">Bar 1</label>
</div>

<div prop="foo">
  <input type="radio" prop="bar">
  <label for="bar">Bar 2</label>
</div>

Such a mechanism could also be useful for other Web Platform languages, e.g. CSS also often needs element references, and has the same problem with ids (e.g. see element() ).

Solution 3: Keywords for common cases

A lot of the use cases for relative references only need referencing a previous or next sibling. If a more general solution is infeasible (or lacks implementer interest), introducing keywords for these common cases would address a lot of author needs. Disambiguation may be more of a problem in that case, as it doesn’t make sense to introduce entirely new attributes for this.

5 Likes

I just want to note the relation between this proposal and one of the motivating use cases for AOM:

  1. Setting relationship properties without needing to use IDREFs
    • Currently, to specify any ARIA relationship, an author must specify a unique ID on any element which may be the target of the relationship.
1 Like

Oh yes, ARIA is a huge use case here. I can’t believe I forgot to include it. Will update original post, thanks!

1 Like

For the common case of form elements maybe there should just be a tag like

<inputset>
  <label>elm</label>
  <input>
</inputset>

that just automatically wires its descendants up. Usually need a wrapper anyway and where you don’t it could be set to display: contents. It could be extended for wiring up error messages and descriptions too, given a native way to express those (yes please).

3 Likes

I like this approach. If it can also somehow solve the label in the light DOM and input inside a shadow DOM problem then this would be great (ok, I know that’s probably not the case but I had to try…)

1 Like

I like the suggestion, but I have concerns about “scoped IDs” ; particularly if JavaScript is bound to an ID, and there are multiples, I could see some issues.

In tables, for <td> and <th> we get the headers attribute; it allows setting one or many labels for a cell. This allows me to set a content-to-label relationship with <td headers="someLabel">Pizza</td>.

But Tables also offers a “label-to-parent” relationship with the scope attribute. I can have <td scope="row">Foods</td> which effectively means, "this is a label for everything coming after it in the same parent element (a row).

Tables also has the ability to jump up to a grandparent, for setting scope: <td scope="rowgroup">Things we consume</td>. This means the label is labeling everything in the tbody, thead, or tfoot.

I know I’m stating what we already know, but the point is mostly that we have some model for this already in how we make tables. Tables offer these kinds of relationships:

  • label to siblings
  • label to parent scope
  • content to label

Right now, the <label for="" /> sets label-to-content relationship. And… that’s it.

So, why can’t we adopt the concepts we use to create label-to-parent-scope and label-to-siblings relationships from tables and apply them to forms, or even all elements ?

  1. Why can’t I assign a label from the perspective of the input? <input headers="some-label" >
  2. Why can’t I write <label scope="label"> to apply to downstream elements?
  3. Why can’t I write <label scope="fieldset">to go up to a parent or grandparent.

I’m asking because, in particular a scope attribute allowed on <label> would eliminate the need to use an id, but still accomplish the goal of referencing another element. And it would have the benefit of being a pattern used elsewhere.

1 Like

I love solution 1 because it would seem to fix a problem I’ve found with the Shadow DOM. That problem is trying to pass a <datalist> in so an input can provide a developer controlled set of autocomplete options.

The <datalist> can only be referenced by the id to the list attribute on input. Since the datalist is given through a slot, its ID isn’t a technical part of the Shadow DOM. So in order to make this work you need to copy all the entries into one within the component and keep them up-to-date as things change. Creating a bunch of extra work and potential bugs.

<input rellist=“:slotted(datalist)”> could be a neat way to handle such a situation.

1 Like

Do you think this could apply to all cross shadow boundary relations in some way? Really cool to see how it might support ::slotted(). For the relfor approach, does that imply the need for similar relaria-labelledby et all attributes to ensure this applies across the ID ref ecosystem?

Do you think this could apply to all cross shadow boundary relations in some way?

I think that’s a very important use case, yes. Thanks for pointing it out.

For the relfor approach, does that imply the need for similar relaria-labelledby et all attributes to ensure this applies across the ID ref ecosystem?

It would, hence why I’m really hoping there’s a way to use the existing attributes and disambiguate, because being stuck with relaria-labelledby or aria-rellabelledby or whatever would be pretty awful. The downside of that is that anything that would be sufficiently web compatible, would be pretty awful to type, to avoid conflicts. E.g. see how weird the syntax for :~:text fragments is, for the same reason.

Alternatively, we could come up with new names entirely, for attributes that cover both relative and absolute syntax (e.g. solution 1 above already is a strict superset of any existing IDREF) instead of having different, worse names for relative syntax. Then, we could deprecate the previous IDREFs and recommend use of the new attributes.

@leaverou, from your problem statement I see that the pain points are:

  1. Duplicate IDs break the one-to-one relationship usually expected when using IDREFs
  2. Declaring IDs on elements automatically creates global JS variables, which is an unnecessary overhead

I like your proposed solution for the scoping but I think we can solve more issues if we simplify it. (Edit: by “simplify,” I mean removing the prop="foo" requirement, because that would land us in the same place if each prop needs to be unique too.) What if adding an attribute of refscope to an element did this:

  1. Prevents IDs declared in the scope from being made into global variables
  2. Creates a donut scope where IDs and IDREFs can only be paired in the scope (any IDs or IDREFs in nested refscopes or outside the current refscope are ignored when creating the relationships)
  3. Institutes a last-write-wins policy for figuring out which duplicated ID is the “right” one.

Then copy-pasted chunks of HTML would work as expected as long as there is/are proper refscopes.

Example: This chunk can be present multiple times in a document without errors

<div refscope>
  <label for="foo">an input</label>
  <input id="foo" type="text" />
</div>
1 Like

@leaverou Another use case is the <id> value in CSS Directional Focus Navigation (albeit not supported in any browsers).

I wrote a sketch to rough out the idea: https://codepen.io/jimmyfrasche/pen/yLoeRQm

I reused label with a type=description for elements that should set a description. That would be an orthogonal change I’m not especially committed to but it felt like the right thing to do. I wouldn’t mind if there were a label type=error as well but it was too saturday for me to work that out. The rest of the semantics are how I imagine it, though.

Of the three possibilities I like solution 2 the best but it’s a bit confusing that using a prop creates a new scope. I think it would be easier to reason about if there were an explicit propscope making the original example:

<div propscope>
  <input type="radio" prop="bar">
  <label for="bar">Bar 1</label>
</div>

<div propscope>
  <input type="radio" prop="bar">
  <label for="bar">Bar 2</label>
</div>

This is starting to look worryingly much like Microdata :crazy_face:

I even looked up how itemscope worked to double check that I wasn’t introducing new behavior/spelling. Seemed like a fitting precedent.

I found a problem with messing refscopes: for open slots of a component (like in a shadow DOM), you’ll want to be able to opt the children out of the scope. For example:

<template id="my-component" refscope>
  <p>I'm a web component with a slot</p>
  <slot>
    <p>but the contents of this slot should be able to be referenced by elements outside of this component</p>
  </slot>
</template>

If we introduce a mechanism to create scopes for IDs and IDREFs, we’ll likely need a mechanism to end them. Sometimes the author will want to end only the containing scope but sometimes they’ll want to end several or all.

Ending the current scope and ending all scopes might be all we need. Maybe adding a couple of optional values to the refscope attribute will be enough.

<template id="my-component" refscope>
  <p>I'm a web component with a slot</p>
  <slot refscope="end">
    <p>the contents of this slot are able to be referenced by elements in the containing scope</p>
  </slot>
  <slot refscope="end-all">
    <p>the contents of this slot are able to be referenced by unscoped elements</p>
  </slot>
</template>

An issue I see with using some kind of selector syntax is that selectors could match more than one element and then we’re back in the same problem space of needing to disambiguate which element is intended.

<input type="radio" class="cool" name="this_one" />
<input type="radio" class="cool" name="that_one" />
<label for="input[type='radio'].cool">
  which input do I label?
</label>

ids can be duplicate too, in practice, but somehow the browser doesn’t throw its hands up in the air in that case :slight_smile:

If there are multiple matches it can do the same thing document.querySelector does but that also means that something can work and then a change in unrelated html can break the relationship which will be hard to notice.

The same is true of id if the unrelated change introduces a duplicate id but at least all you have to do then is search for that id to find the duplicate instead of having to figure out what new element also matches an arbitrary selector.

You could also have a duplicate prop but since they’re limited in scope you only need to examine that scope.

Of the three, I’d rather debug prop. (Or use an inputset element and not have to worry about anything :smile:)