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

Consider adding a headinglevelstart attribute #5033

Open
annevk opened this issue Oct 22, 2019 · 117 comments
Open

Consider adding a headinglevelstart attribute #5033

annevk opened this issue Oct 22, 2019 · 117 comments
Labels
accessibility Affects accessibility addition/proposal New features or enhancements

Comments

@annevk
Copy link
Member

annevk commented Oct 22, 2019

See the suggestion by @muan at #3499 (comment):

<h1>GitHub</h1>
<h2>jsdom/jsdom</h2>
<div>
<article headinglevelstart="3">
   <h1>jsdom</h1>
   <h2>Basic usage</h2>
   <h2>Customizing jsdom</h2>
   <h3>Simple options</h3>
   ...
 </article>
</div>

cc @whatwg/a11y

@annevk annevk added addition/proposal New features or enhancements accessibility Affects accessibility labels Oct 22, 2019
@scottaohara
Copy link
Collaborator

scottaohara commented Oct 22, 2019

Reading through the other thread, I'd prefer this approach of an opt in instead of just overwriting heading levels.

The only question I have is what is the expectation for something like:

<h1>GitHub</h1>
<h2>jsdom/jsdom</h2>
<div>
<article headinglevelstart="5">
   <h1>jsdom</h1>  
   <h2>Basic usage</h2>
   <h2>Customizing jsdom</h2>
   <h3>Simple options</h3>
   ...
 </article>
</div>

Expose h5, h6, h7 instead? h7 obviously being the outlier here.

Quickly looking at some of the different exposed levels from the platform mappings / trees exposed by browsers, a level 7 should work, but JAWS and NVDA range from getting a little tripped up at times, to JAWS with Firefox just not exposing the levels beyond 6 at all, instead reverting to level 2 or the default level of the heading element used.

Again, it seems the screen readers would need to be updated to account for heading levels beyond 6. Unfortunately, that also means that anyone using an older screen reader, even with a newer browser, may well not get optimal output.

@jimmyfrasche
Copy link

This would solve all my problems with hN tags: user/plugin generated content in templates and template partial that use hN tags. The template including any of the above can just tell it where to start.

@aardrian
Copy link

aardrian commented Oct 28, 2019

If an author can identify what value needs to be set in headinglevelstart, then it stands to reason the author can increment that in their own code for each descendant heading instead of offloading that burden to the user agent.

Pseudo-code with structure borrowed from above:

<h1>GitHub</h1>
<h2>jsdom/jsdom</h2>
<div>
<article>
   <h{1 + $headinglevelstart}>jsdom</h{1 + $headinglevelstart}>  
   <h{2 + $headinglevelstart}>Basic usage</h{2 + $headinglevelstart}>
   <h{3 + $headinglevelstart}>Customizing jsdom</h{3 + $headinglevelstart}>
   <h{3 + $headinglevelstart}>Simple options</h{3 + $headinglevelstart}>
   ...
 </article>
</div>

In other words, the headinglevelstart attribute on its own does not seem to offer anything a developer cannot do now.

For example, in this Codepen example from 2016 I use data-level to do seemingly exactly what this attribute proposes (though I would prefer a server-side solution over client-side or in the UA).

Though neither approach prevents an author from choosing a value that renders as <h7> or above, completely nullifying the benefit for many screen reader users (see @scottaohara's comment above).

@LJWatson
Copy link

LJWatson commented Oct 28, 2019

@aardrian wrote:

If an author can identify what value needs to be set in headinglevelstart, then it stands to reason the author can increment that in their own code for each descendant heading instead of offloading that burden to the user agent.>

The priority of constituencies puts users and authors before implementors, and this seems to be one of the times when that prioritisation is needed. Authors struggle with heading levels as it is, and this has an impact on users. If we ask authors to take on handling more, I'm afraid things will only get harder for authors and worse for users.

If the UA does the work, it has other advantages too:

  • Parameters can be set to handle things like heading levels beyond six.
  • Simpler authoring code is less error prone and less likely to break.
  • Less authoring code may help performance.
  • Enables progressive enhancement.
  • Familiar technique (as @Dan503 noted).

On this last note, would it be possible to extend the capability of the start attribute, instead of minting a new headinglevelstart attribute?

@Dan503
Copy link

Dan503 commented Oct 28, 2019

Would it be possible to extend the capability of the start attribute

I like how short the "start" attribute is but if it only contains a number in it, it isn't as clear what it is doing as it is on ordered lists.

If you have <article start="h3"> though, that is pretty clear what the intention is.

@patrickhlauke
Copy link
Member

If an author can identify what value needs to be set in headinglevelstart, then it stands to reason the author can increment that in their own code for each descendant heading instead of offloading that burden to the user agent.

In fairness, from a development point of view, using headinglevelstart would not require potential reprocessing of content. Assuming in the example above the actual <article> content comes from somewhere else (stored like that in a database, coming from a third-party via an API call), the author can simply define the template

<h1>GitHub</h1>
<h2>jsdom/jsdom</h2>
<div>
  <article headinglevelstart="3">
    {INSERT THE STUFF HERE}
  </article>
</div>

without having to server-side re-munge the content to reprocess all heading levels appropriately. Otherwise, particularly for third-party stuff, they'd have to always proxy content and do, in naive terms, do a find/replace of any heading elements (either native <hx> ones or ARIA-based headings and their aria-level="x") to bump their number.

@aardrian
Copy link

@LJWatson, to your point about the Priority of Constituencies, I feel that pushing the logic to the UA creates a black box that will make it easier for developers to ignore, resulting in a generally worse experience for users. And I also want a better experience for users (over authors).

@patrickhlauke, that is a good point (and also made by @LJWatson). And yes, the client-side processing is potentially more burden for users than broken / opaque headings.

Perhaps something similar to the code/approach I posted above could be the basis for a polyfill (though it still won't help current SRs).

@bkardell
Copy link
Contributor

bkardell commented Oct 28, 2019

This mostly addresses what I was interested in in this comment a while ago - though, it still seems to me we could probably just figure that out with simply a marker attribute rather than an explicit level, and that would be better for authors. Still, this very much seems like progress and worth doing if that's not plausible.

@jimmyfrasche
Copy link

@aardrian

If an author can identify what value needs to be set in headinglevelstart, then it stands to reason the author can increment that in their own code for each descendant heading instead of offloading that burden to the user agent.

That is simple for simple cases but it gets more complicated quickly.

The approach you outlined works fine within a single template file whose content is all under the control of the author (but, in that limited context, so does just using h-tags, which are also easier to read).

Here is where it hurts:

When a template includes another template the counter needs to be both threaded through the template and used by the child templates as well. Many templating languages support this, though not all make it super pleasant, but it's not of much use unless the entire project agrees upon the convention. That's easy on greenfield projects but legacy code requires more work or when there's a "parent theme" and you end up needing to modify all of its templates even if you're making no other changes. On hosted platforms the choice of templating language is usually fixed so whether this can be done at all can be out of one's hands.

Sometimes html comes from a blackbox plugin. This is no one's favorite but it happens distressingly often. The generated html can be parsed and its headers shifted by walking the AST allowing it to be re-rendered correctly, but this is fairly complicated and slow so it's unlikely to happen and probably needs to be fixed up on the client side with some javascript.

If the platform had the concept of the heading level baked into its core and took care of threading it through templates and plugins, all of which agreed to use this feature, this wouldn't be as much of an effort. To my knowledge, there is no such platform or at least it is not among the most commonly used. Even if all the popular ones added it today it would take quite a while for it to see widespread use and for legacy code bases to get on board even if their platform now supported it.

That still leaves user-generated content. If it's stored in some non-html source or some IR that can make it easy to generate starting at whatever level is necessary (assuming it's an option: it usually is not). Often it's stored in html that was created by some kind of WYSIWYG editor. If every instance of the user generated content starts at, say, h2, you could configure the header to not allow creating h1 tags. If this is a legacy project or a project going under redesign that means going through the database and parsing all the entries and shifting the headings. Not pleasant but it's a one time thing. If you're trying to use the content where the starting header is different in different contexts you're back to fixing it up on the fly.

(I haven't looked into it too deeply yet, but it seems like wordpress's gutenberg would have many of these problems simultaneously).

None of this is insurmountable but it's enough to surmount that it doesn't seem to ever get surmounted.

Having an attribute may mean occasionally having to add a div to hang it off of but the outlining solutions could also mean having to insert an extraneous tag and generally one of greater semantic weight and its less explicit that those tags are necessary to maintain the correct document outline. An attribute would also make it easier to fix up a legacy codebase with just a few simple edits.

Another thing I like about having an attribute is that it could possibly have extra modes in the future like "none" to mean "ignore any headings in this subtree" (useful for teasers whose teased body should not be contributing to the document outline) or something to mean "this came from an editor apply a slower algorithm to shift the headings so that are no gaps like h3 to h6 with no intervening h5".

@aardrian
Copy link

@jimmyfrasche, I feel bad that my response is so brief as to seem dismissive, but I understand your points and agree with many. After my last comment I am not pushing my model anymore as I made my case (and nobody seems down with it). But I still think a polyfill may be helpful.

@muan
Copy link
Member

muan commented Sep 15, 2023

Notes from TPAC, slide for context.

What this proposal do: help web authors prevent user generated content from breaking the page heading structure.
What this proposal doesn't do: prevent misuse of heading levels and skipping levels.

To clarify, this is the expected affect:

Code

<h1>jsdom/jsdom</h1>
<h2>Files</h2>
<h2>README.md</h2>
<div headinglevelstart="3"><!-- user generated content starts -->
  <h1>jsdom</h1>
  <h2>Basic usage</h2>
  <h2>Customizing jsdom</h2>
  ...
</div>
<h2>About</h2>
  <h3>Resources</h3>
  <h3>Licenses</h3>
<h2>Releases</h2>
...

Heading structure

h1 jsdom/jsdom
	h2 Files
	h2 README.md
		h3 jsdom
			h4 Basic usage
			h4 Customizing jsdom
	h2 About
		h3 Resources
		h3 licenses
	h2 Releases

with an addition to the proposal:

interface HTMLHeadingElement {
  [CEReactions] readonly attribute unsigned long level;
};

In case of

<div headinglevelstart="2">
  <article headinglevelstart="6">
    <h1><h1>
    <h2 aria-level="1"><h2>
  </article>
</div>
$ h1.level // AAM level 7
7
$ h2.level // AAM level 1
8
$ h2.ariaLevel
1

Todo:

  • Naming bikeshed?
    • At TPAC it was suggested that headinglevelstart is good in that it implies this only looks at the closest parent element
  • Polyfill or explore the possibility for an experimental feature to get AT user feedback
  • Getting standards positions (+1 from Chrome from TPAC)
  • Draft spec

Future to do:

@smaug----
Copy link

Also discussed at TPAC: need to define how this works with Shadow DOM. Most like the level should be computed based on the shadow including ancestor chain.

@scottaohara
Copy link
Collaborator

scottaohara commented Sep 19, 2023

For a lot of use cases, this will probably be fine as long as the headings remain in the 1 to 6 level range. But things are slightly better than I mentioned 4 years ago with browser/screen reader support for levels beyond 6.

Safari and Firefox expose the specified level if one goes beyond level 6, but Chrome/Edge appear to cap out at level 9, and beyond that heading levels get exposed as a level 2 regardless of the specified value.

Vispero/JAWS would need to be looped in on this, as levels beyond 6 get treated as level 2 - regardless of if the browser exposes the specified level or not. I also noticed that NVDA doesn't include headings between 7 and 9 when navigating with the H key with Chrome, but seems to do it fine with Firefox. So, also something they should be looped in on, as well.

I mention all of the above specifically in context to:

Polyfill or explore the possibility for an experimental feature to get AT user feedback

Since a polyfill won't be enough without Chrome and Edge/UIA being modified to expose levels beyond 9, and JAWS changing their treatment of higher levels.

Fairly recently, the Editor's draft for ARIA included this note for aria-level

On elements with role heading, values for aria-level above 6 can create difficulties for users. Also, at the time of this writing, most combinations of user agents and assistive technologies only support aria-level integers 1-9 on headings.

The important part of that note - as support for higher levels can obviously change with work - is more the fact that the higher the potential heading level, the more difficult the content may be to understand/navigate for someone using a screen reader.

So with that said, I'm supportive of the intent behind this attribute and how it can make heading levels easier to adjust for developers when necessary, but was there any talk of a level cap for this attribute at TPAC? Making sure all browsers/screen readers support up to level 9 seems reasonable. That's at least what the ARIA wg discussed when creating that note. But beyond level 9 maybe seems a bit much? Someone being able to declare headinglevelstart="43" seems a bad idea.

cc @aleventhal @jnurthen

@patrickhlauke
Copy link
Member

But beyond level 9 maybe seems a bit much?

I suspect that as soon as you introduce a reasonable-sounding cap, somebody will come along with a particularly complex document/site that requires one more than the cap...

@scottaohara
Copy link
Collaborator

scottaohara commented Sep 19, 2023

I mean, they must be getting by somehow right now, with their six-level limitation, though maybe they're not. so yeah, that's fair point @patrickhlauke. Author guidance to allude to the potential cons then, at least?

@muan
Copy link
Member

muan commented Sep 19, 2023

but was there any talk of a level cap for this attribute at TPAC? Making sure all browsers/screen readers support up to level 9 seems reasonable. That's at least what the ARIA wg discussed when creating that note. But beyond level 9 maybe seems a bit much? Someone being able to declare headinglevelstart="43" seems a bad idea.

Yes, @mcking65 and @spectranaut was at the WHATWG meeting. Matt brought up exactly this issue. I am personally not opposed to capping the levels but agree as @patrickhlauke said.

The level problem was why future to do (as author guidance) was included, since user misuse of the attributes (aria-level now, headinglevelstart in the future) were mostly going to be inevitable:

Future to do:

Happy to add a cap in a draft spec, but regardless I hope it won't block this proposal, as for the alternative seems to be what led to this proposal to begin with 🙈 .


Since a polyfill won't be enough without Chrome and Edge/UIA being modified to expose levels beyond 9, and JAWS changing their treatment of higher levels.

FWIW my plan was to leave this up to further feedback when I create the polyfill, with capping at 6 or 9, or none.

@scottaohara
Copy link
Collaborator

thanks @muan - yeh i saw the 'future to do', but i would just submit that author guidance should be added with the proposed spec update and also keep the future to do for checkers to consider adding guidance as well. Whatever guidance is added would be contingent on the decision to cap or not, but that shouldn't be a blocker.

@muan
Copy link
Member

muan commented Sep 21, 2023

I've initialized a polyfill with some open issues. Feel free to open issues and let me know if you'd like to be a contributor! I'll carry on with the rest of the to-dos.

@tomByrer
Copy link

Does not seem that Firefox & Chromium have any inherent CSS formatting for <h7>+ markup; currently inline / default / ignored. So that will have to be added (though current <h6> somehow became too small by default).
https://codepen.io/tomByrer/pen/abPYdoG

Paging @jakearchibald; IIRC years ago he wanted something like <h> tags with auto-levels? Or maybe argued the opposite?

@muan
Copy link
Member

muan commented Sep 25, 2023

@tomByrer FWIW with this proposal there is never going to be <h7>, the only difference is that regular h* tags will be exposed to AT as level up to 9.

for styling @annevk had previously suggested a functional pseudo-class selector.

@annevk
Copy link
Member Author

annevk commented Sep 25, 2023

I think we should add :heading as well, as per my prior proposal.

@jakearchibald
Copy link
Contributor

jakearchibald commented Sep 25, 2023

Would <div headinglevelstart> work? As in, can there be an 'auto' value?

<div headinglevelstart="2">
  <h1>Hello</h1>
  
  <div headinglevelstart>
    <h1>World</h1>
  </div>
</div>

Where <h1>World</h1> would be level 3, since the auto value of headinglevelstart would be +1 of the parent headinglevelstart.

@Dan503
Copy link

Dan503 commented Sep 25, 2023

@muan

h* tags will be exposed to AT as level up to 9.

Why only up to level 9?
Why does there have to be any limit at all?

I see having a limit of any kind as an unnecessary restriction on what authors can implement in their layouts.

@romainmenke
Copy link

Things like Gutenberg (the thing replacing TinyMCE in WordPress) allows you to nest components.

So you might have a rows component that itself can contain a list of any other component, including more rows.


Keeping track of the last used heading level is also something I am considering but I think it will require re-parsing all preceding content.

Any component can render whatever HTML it wants. There isn't a hard requirement that they must use a specific function to render headings or that they must trigger a callback. The only way to determine the previous heading with certainty is by buffering the output and re-parsing it.


Exposing headingoffset to content editors might be the better approach (for now). Only content editors can have a good view on the complete document they are creating.

@keithamus
Copy link
Contributor

One potential gotcha with a cumulative headingoffset we've discovered is that when applying it to a larger document as a replacement for <h2..6> tags, there are certain times when you need to reset - namely dialogs. Consider:

<main headingoffset=0>
  <h1>Settings</h1>
  <section headingoffset=1>
    <h1>Profile Settings</h1><!-- this is effectively h2 (1+1) -->
    ...
  </section>
  <section headingoffset=1>
    <h1>Account Settings</h1><!-- this is effectively h2 (1+1) -->
    <button invoketarget=delete_dialog>Delete my account</button>
    <dialog id=delete_dialog>
      <h1>Delete Account - Are you sure?</h1><!-- this is effectively h2 (1+1) -->
      <form method=dialog>
        <button type=submit>Yes</button>
      </form>   
    </dialog>
  </section>
</main>

In this example, ideally the content structure would be h1 "Settings", h2 "Profile Settings", h2 "Account Setings", and the <dialog> should contain an h1 "Delete Account - Are you sure?".

Potential solutions?

Unfortunately because headingoffset accumulates, and the h1 does a tree-walk, we're kind of stuck with an incorrect document structure that is potentially worse than the without the feature. Some viable options I see for a path forward:

Stop Tags

Allow elements to stop the tree walk - so if the check sees a <dialog> it stops tree walking and uses whatever result it has accumulated. Effectively:

let node = this;
let offset = 0;
while (node = node.parentElement) {
  if (node.localName == 'DIALOG') break;
  offset += node.headingOffset;
}

Of course the downsides here are that the dialog tag name is somewhat arbitrary, it doesn't for example account for role=dialog. Also non-modal dialogs probably want to participate. I can see the if getting gradually more complex as more use cases appear.

Stop Attribute

Adding a headingstop or headingreset or headingoffset=reset or pleasestopaccumulatingtheheadingoffsetatthispoint attribute that acts as the same stop signal but in a more explicit & opt in manner, meaning the <dialog> needs to become <dialog headingstop>.

The downsides being that it's more markup to undo stuff that was already added via markup.

Allowing negative headingoffset

We could allow negative offsets to undo the shift from other elements. In the above example <dialog> needs to become <dialog headingoffset=-1>.

The downsides are more markup, and arguably more confusing markup, as well as this potentially not really solving the problem. The desire here is to reset the headingoffset to 0, but we're just translating that problem into a math problem instead. Yet another opportunity for off-by-one errors.

Go back to absolute headingstart

We can do the ops suggestion of using absolute levels, and avoid doing a cumulative tree walk. This way in the above example <dialog> would need to become <dialog headingoffset=0>.

The downsides are that we aren't doing cumulative offsets, which are quite useful.

Let developers figure it out: use aria-level

Developers can of course always set an absolute aria-level on every element. This way, in the above example, <dialog>'s <h1> would need an explicit aria-level... as would any other heading inside the dialog.

The downside is this probably creates the most markup of all solutions.

Let developers figure it out: move the dialog out of the container and into the top level of the DOM.

If the markup is adjusted so that the <dialog> is a direct descendant of <main> this problem effectively goes away. However for teams building micro frontends, or frankly many other component models, this is effectively a blocker as a lot of the time they won't have the facilities to put elements at arbitrary positions in the DOM.


I'm sure there are many other solutions to this. If anyone has any ideas, or preferences or points about the above, I'm all ears. I've written enough now though, and I'd rather other people write some words to counter balance the amount of words I've written.

@jimmyfrasche
Copy link

For a modal dialog I don't see what would be gained by doing anything other than always resetting to 0 and I can't think of other places where you would want to do that. Is it possible to just special case modal dialogs? Alt: add headingoffset=reset and make it implicit for dialogs unless otherwise specified.

@annevk
Copy link
Member Author

annevk commented May 13, 2024

As a reminder, if we do something special for the dialog element we shouldn't also do it for role=dialog. HTML semantics shouldn't be directly influenced by ARIA.

@jakearchibald
Copy link
Contributor

Seems reasonable to have a headingreset attribute. headingoffset=reset means that the reflecting property would need to return a string for a thing that's mostly numbers.

@keithamus
Copy link
Contributor

keithamus commented Feb 28, 2025

@smockle and I worked a bit more on the prototype today, as well as a draft spec (the core of the algorithms are here: https://whatpr.org/html/11086/005b463...d43c176/sections.html#heading-levels-&-offsets - so folks who are interested please review and share your thoughts).

As it stands:

  • headingoffset is an attribute which can take a positive integer. It can go on any element including <div>, <body>, <slot>, etc.
  • h1-h6 tags will calculate their level by walking the "flat tree" and collecting all headingoffset values, summing them, plus their tag name, to get their computed level.
  • The possible values are limited from 1-9, clamped. So a headingoffset=10 will make any child h1-h6 tags the equivalent of h9. While h7-h9 do not exist, ARIA allows up to heading level 9.
  • headingreset is a boolean attribute, its presence will act as the "stop point". When a header traverses, it returns the accumulated offset the first headingreset it sees.
  • <dialog> elements that are :modal will always implicitly have headingreset. There's no way to change this, but <dialog headingoffset=N> will work as you expect.
  • I think this test file outlines many of the combinations so if you're curious about a particular combination, it's worth consulting the test file.

@annevk
Copy link
Member Author

annevk commented Mar 3, 2025

I don't think we should do this without pseudo-classes, as previously mentioned. I also wonder if we can cover these somehow in the user agent style sheet. It seems we could replace h1 through h6 in https://html.spec.whatwg.org/multipage/rendering.html#sections-and-headings (when used to set margin, font-size, and font-weight) with their equivalent pseudo-class. There might be some specificity challenges though.

@tabatkins
Copy link
Contributor

It would be very easy to sell and specify the pseudo-classes if there's actually impl interest here; the previous attempts just died because nobody picked up the outline algorithm so there was no real point.

We do need to be careful about specificity; we have a standing "do it if it proves necessary" to extend :where() to allow specificying the specificity rather than making it 0, but this can also be hacked around: :is(a:not(*), :where(:heading(1))) matches :heading(1) but with the specificity of a.

@keithamus
Copy link
Contributor

Very happy to try and specify :heading(n) and the respective UA sheet. I’m glad folks are on board because this was our anticipated next step 😊

@annevk
Copy link
Member Author

annevk commented Mar 4, 2025

FWIW, I think we essentially want what I specified back in 2018 (in #3499):

    <dt><dfn data-export="" data-dfn-type="selector"><code
    data-x="selector-heading">:heading</code></dfn></dt>
    <dt><dfn data-export="" data-dfn-type="selector"><code
    data-x="selector-heading-function">:heading()</code></dfn></dt>
    <!-- TODO: should these be data-noexport? -->
    <dd>
     <p>The <code data-x="selector-heading">:heading</code> <span>pseudo-class</span> must match any
     element that is a <span data-x="concept-heading">heading</span>.</p>

     <p>The <code data-x="selector-heading-function">:heading()</code> functional
     <span>pseudo-class</span> accepts a <dfn data-x="selector-heading-function-level">level</dfn>.
     <span data-x="selector-heading-function-level">Level</span> must be a positive
     <span>&lt;integer></span>. The <code data-x="selector-heading-function">:heading()</code>
     functional <span>pseudo-class</span> must match any element that is a <span
     data-x="concept-heading">heading</span> whose <span>heading level</span> is <span
     data-x="selector-heading-function-level">level</span>. <ref spec=CSSVALUES></p>
    </dd>

(For maximum clarity, heading level above is not impacted by ARIA in any way.)

@tabatkins
Copy link
Contributor

I do want it to accept An+B, rather than just an integer, to allow for selecting all headings above/below a certain level, like :heading(3+n) for h3, h4, etc.

@SaekiTominaga
Copy link

@keithamus In line 55 of the test file, the heading level in the <dialog> is written as Level 9, is this a mistype of Level 1?

  <!-- Negative -->
  <div headingoffset="-3" title="container headingoffset=-3">
    <h1><!-- Level 1, 1 + 1 --></h1>
    <h2><!-- Level 2, 8 + 2 (clamped) --></h2>
    <h3><!-- Level 3, 8 + 3 (clamped) --></h3>
    <h4><!-- Level 4, 8 + 4 (clamped) --></h4>
    <h5><!-- Level 5, 8 + 5 (clamped) --></h5>
    <h6><!-- Level 6, 8 + 6 (clamped) --></h6>
    <div headingreset title="container headingreset"> <!-- h1s are now h1s -->
      <h1><!-- Level 1, h1 (headingreset)--></h1>
    </div>
    <dialog open title="container dialog"> <!-- h1s are still h9s -->
      <h1><!-- Level 9, 8 + 1 --></h1>
    </dialog>
  </div>

@keithamus
Copy link
Contributor

@SaekiTominaga no, that's per design. non-modal <dialog> elements are not headingreset, only :modal dialogs. There's a separate test demonstrating how a modal dialog becomes headingreset

@keithamus
Copy link
Contributor

keithamus commented Mar 8, 2025

One thing that came out of the code prototype is that the name headingreset is probably confusing. In fact we've ended up dropping in a fairly substantial code comment to explain the particular piece of logic:

    if (!element || element->headingReset()) {
      // When encountering a headingreset, it's important to return the
      // existing accumulated offset, to allow for deeply nested trees having
      // children with headingoffsets. Returning to 0 would create a confusing
      // structure for authors. Consider a deeply nested structure like:
      //
      // <div headingreset>
      //   <h1>First Heading</h1>
      //   <div headingoffset=1>
      //     <h1>Second Heading</h1>
      //   </div>
      // </div>
      //
      // The "First Heading" should be an h1 (h1+reset), but importantly
      // the "Second Heading" should be an h2 (h1+offset=1+reset). As it walks
      // the tree, it will accumulate the `headingoffset=1`, then land on the
      // headingreset, where we hit this line of logic. Our choices are to
      // return either `0` or `1` (the accumulated offset). Returning `0` makes
      // "Second Heading" an h1, which is the incorrect heading for the ideal
      // document structure here. Returning the accumulated offset of 1 makes
      // it an h2 which is a better heading structure.
      // Ultimately, `headingreset` may be a poor choice of name, and perhaps
      // something more like `headingoffsetboundary` better describes the logic
      // of this attribute.
      return std::min(offset, max_offset);
    }

As you can see from this comment, headingreset can be construed as "when you reach this, set the computed offset to 0" but that would be the incorrect behaviour.

I think the main confusion lies in your intution about how the offset is computed; if you're imagining that it walks down the tree then headingreset makes intuitive sense. If you're imagining that it wals up from the heading to the root (which is what the implementation does) then it makes less sense.

I think we should consider a less confusing name than "reset". Some (poor) suggestons for names to get ideas going:

  • headingstop
  • headingoffsetboundary (or maybe headingboundary)
  • headingbreak
  • headinghalt
  • stopheadingoffset
  • headingroot (my favourite)
  • headingunset
  • headingrevert
  • headinginitial

@mttshw
Copy link

mttshw commented Mar 8, 2025

One thing that came out of the code prototype is that the name headingreset is probably confusing. In fact we've ended up dropping in a fairly substantial code comment to explain the particular piece of logic:

if (!element || element->headingReset()) {
  // When encountering a headingreset, it's important to return the
  // existing accumulated offset, to allow for deeply nested trees having
  // children with headingoffsets. Returning to 0 would create a confusing
  // structure for authors. Consider a deeply nested structure like:
  //
  // <div headingreset>
  //   <h1>First Heading</h1>
  //   <div headingoffset=1>
  //     <h1>Second Heading</h1>
  //   </div>
  // </div>
  //
  // The "First Heading" should be an h1 (h1+reset), but importantly
  // the "Second Heading" should be an h2 (h1+offset=1+reset). As it walks
  // the tree, it will accumulate the `headingoffset=1`, then land on the
  // headingreset, where we hit this line of logic. Our choices are to
  // return either `0` or `1` (the accumulated offset). Returning `0` makes
  // "Second Heading" an h1, which is the incorrect heading for the ideal
  // document structure here. Returning the accumulated offset of 1 makes
  // it an h2 which is a better heading structure.
  // Ultimately, `headingreset` may be a poor choice of name, and perhaps
  // something more like `headingoffsetboundary` better describes the logic
  // of this attribute.
  return std::min(offset, max_offset);
}

As you can see from this comment, headingreset can be construed as "when you reach this, set the computed offset to 0" but that would be the incorrect behaviour.

I think the main confusion lies in your intution about how the offset is computed; if you're imagining that it walks down the tree then headingreset makes intuitive sense. If you're imagining that it wals up from the heading to the root (which is what the implementation does) then it makes less sense.

I think we should consider a less confusing name than "reset". Some (poor) suggestons for names to get ideas going:

* `headingstop`

* `headingoffsetboundary` (or maybe `headingboundary`)

* `headingbreak`

* `headinghalt`

* `stopheadingoffset`

I've been reading through this trying to work out how its confusing but honestly i think you guys are overthinking it. headingreset makes perfect sense to me.

@Crissov
Copy link

Crissov commented Mar 8, 2025

To be frank, I don’t quite understand why the new attributes need to start their names with ”heading“ when all the respective element names just have ”h“.

”offset“ is a fine choice for the additive variant. If the absolute variant would be specified, I think ”level“ or ”base“ would work well.

The usual offset will probably be 1. It makes no sense to introduce an auto value to mimic that. It may be worthwhile to test a missing value as 1, though.

If some components or elements need to edles that their heading elements’ levels have to be taken absolutely, I think they establish a root level, which should be exposed in the attribute name.

<section hoffset>
  <h1>level 2</h1>
  <dialog hroot>
    <h1>level 1</h1>
  </dialog>
</section>

@AleksandrHovhannisyan
Copy link

AleksandrHovhannisyan commented Mar 8, 2025

I prefer the more verbose and explicit heading prefixes. h is mostly idiomatic for heading levels by now but keep in mind these proposed attributes would go on potentially any HTML element.

At first I thought headingreset was fine, but after reading the definition and examples more closely it seems like a misnomer:

headingreset is a boolean attribute, its presence will act as the "stop point". When a header traverses, it returns the accumulated offset the first headingreset it sees.

"reset" seems to imply the offset is reset to zero, which is not what the code does. I think headingroot has the same problem.

Alternatively, if we decide to go with headingoffset, maybe headingoffset="reset" to avoid API bloat. (This does not fix the naming issue.)

Edit: Actually, I keep going back and forth on headingreset. I'm curious, what is the difference between headingoffset="0" and headingreset (or whatever we end up calling it)?

@keithamus
Copy link
Contributor

To be frank, I don’t quite understand why the new attributes need to start their names with ”heading“ when all the respective element names just have ”h“.

It's a reasonable point, but one I'm personally a little ambivalent to. While html has existing shorthands (rel, img, etc) some claim it to be a bug not a feature. Newer attribute names have erred on the side of verbose (popovertargetaction, playsinline, etc).

The usual offset will probably be 1.

I don't strictly agree. For example we're aiming to integrate this at GitHub for comments, and it'll be 2. But I can acknowledge the point.

It makes no sense to introduce an auto value to mimic that.

The auto value would likely not mimick 1, but would instead find the nearest sibling heading and try to use that. We're punting the auto value for now though, as it's very complex.

It may be worthwhile to test a missing value as 1, though.

Right now if you don't pass a value - or you pass an invalid value - you get 0. I think for this feature that's welcome, as it encourages people to be very precise. I worry that poviding some "default" other than 0 will imply that the default is the preferred use of the API, but it's not.

I think they establish a root level, which should be exposed in the attribute name.

I actually think headingroot is an excellent name instead of headingreset!

Edit: Actually, I keep going back and forth on headingreset. I'm curious, what is the difference between headingoffset="0" and headingreset (or whatever we end up calling it)?

headingoffset=0 is as good as not having the attribute. 0 is the missing/invalid value default so headingoffset=0, headingoffset=bar, headingoffset=-1 will all be 0.

Alternatively, if we decide to go with headingoffset, maybe headingoffset="reset" to avoid API bloat. (This does not fix the naming issue.)

headingoffset=reset was discussed prior (around here), but it was pointed out it makes reflection really awkward (around here). I'd like to keep headingoffset only being a number.

@patrickhlauke
Copy link
Member

I mentioned this on mastodon, but might as well drop it in here: any reason why the attributs couldn't be prefixed with level rather than heading, as they affect the heading level?

@oscarotero
Copy link

For consistency with the <ol> element that has the start attribute, I'd vote for hstart (equivalent to <h1...6> elements) or headingstart (as a more human name) as the attribute name.

I don't think headingreset is needed. Maybe it could be enought with headingstart="reset"

@AleksandrHovhannisyan
Copy link

AleksandrHovhannisyan commented Mar 8, 2025

@oscarotero I think the idea behind offset rather than level/start is that it's relative rather than explicit. So headingoffset="1" says "+1 whatever the previous heading level was". With an explicit heading level, you personally need to remember what the previous level was in the document and set the new start level correctly. With a relative offset, it doesn't matter.

@keithamus I'm still not clear on what headingreset does. Consider this example:

<h1>A</h1>
<div headingoffset="1">
  <h1>B</h1>
  <div headingreset>
    <h1>C</h1>
    <h1>D</h1>
  </div>
</div>

Per my understanding, the following levels will be used:

  • <h1>A</h1>
  • <h2>B</h2>
  • <h2>C</h2>
  • <h2>D</h2>

But in my mind, "reset" (and even headingroot) implies "reset to no offset." So I expect to see:

  • <h1>A</h1>
  • <h2>B</h2>
  • <h1>C</h1>
  • <h1>D</h1>

Which is why I feel "reset" is a misnomer, unless I've completely misunderstood how this attribute works.

@keithamus
Copy link
Contributor

I mentioned this on mastodon, but might as well drop it in here: any reason why the attributs couldn't be prefixed with level rather than heading, as they affect the heading level?

One small concern I have here is that folks might conflate it with aria-level. headingoffset only impacts headings, not, for example, treegrid rows.

For consistency with the <ol> element that has the start attribute, I'd vote for hstart (equivalent to <h1...6> elements) or headingstart (as a more human name) as the attribute name.

The difference here is that headingoffset accumulates, while start is absolute.

I'm still not clear on what headingreset does

It's important to note that accumulation happens by walking up the tree, on each heading. headingreset marks that element as a point to stop the accumulation of offsets. Your example doesn't demonstrate the subtlety because no elements within the headingreset container have a headingoffset, so the accumulated offset is always 0.

Instead consider:

<div headingoffset="1">
  <div headingreset>
    <div headingoffset="1">
      <h1>A</h1>
    </div>
  </div>
</div>

What is A here? I believe it should be h2 because it's inside a container with headingoffset=1, but that container is in a headingreset container. If headingreset made the accumulation return 0 then A would be h1 which isn't right, IMO.

@oscarotero
Copy link

I'm a bit confused about offset and reset. I assume both properties are exclusive (elements only can have one of them). What about something like this?

  • headinglevel="2" Starts the headers level from 2. (<h1> means <h2>)
  • headinglevel="relative" Starts the headers level from the previous level heeader. (If the previous level is <h2>, the first next header is equivalent to <h3>.
  • headinglevel="absolute" The header level is no longer relative (the default value). It's equivalent to headinglevel=1 so maybe it doesn't make sense.

Example:

<h1>Level 1</h1>

<section headinglevel="relative">
    <h1>Level 2</h1>
    <h2>Level 3</h2>
  
    <section headinglevel="relative">
        <h1>Level 4</h1>
        <h2>Level 5</h2>
    </section>
  
    <section headinglevel="absolute">
        <h1>Level 1</h1>
        <h2>Level 2</h2>
    </section>
  
    <section headinglevel="3">
        <h1>Level 3</h1>
        <h2>Level 4</h2>
    </section>
  
    <h1>Level 2</h1>
    <h2>Level 3</h2>
</section>

<h1>Level 1</h1>

@keithamus
Copy link
Contributor

I assume both properties are exclusive (elements only can have one of them).

The current prototype allows for this. You can indeed do something like <div headingreset headingoffset=3> - which means "the next h1 is guaranteed to be level 3". This is the equivalent of your headinglevel=absolute syntax, albeit more flexible yet more verbose.

@plfstr
Copy link

plfstr commented Mar 10, 2025

Re: #5033 (comment)

What about borrowing from CSS? Soheadingunset OR headingrevert OR headinginitial?

aarongable pushed a commit to chromium/chromium that referenced this issue Mar 10, 2025
This is a prototype based on the I2P at
https://groups.google.com/a/chromium.org/g/blink-dev/c/8yl-pJhuLHE/m/1GDufCYWAAAJ.

Implements the `headingoffset` and `headingreset` attributes as
discussed in whatwg/html#5033. The
`headingoffset` attribute acts as a cumulative integer that sets the
heading level of a heading element to that of its tagname, plus the
offset. So a `<h1>` inside a `<div headingoffset=1>` effectively becomes
an `<h2>`.

These changes are gated behind the 'HeadingOffset' runtime flag which is
set to experimental.

AX-Relnotes: Added `headingoffset` to `HeadingLevel`.

Bug: 333628468
Change-Id: I28619e231619ae8541feeefc39ce4a40c1d92e65
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/5445406
Reviewed-by: Kurt Catti-Schmidt <[email protected]>
Reviewed-by: Mason Freed <[email protected]>
Commit-Queue: Keith Cirkel <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1430361}
@zealvurte
Copy link

But in my mind, "reset" (and even headingroot) implies "reset to no offset." So I expect to see:

  • <h1>A</h1>
  • <h2>B</h2>
  • <h1>C</h1>
  • <h1>D</h1>

My reading is that this is the behaviour defined so far. If not, I've misunderstood and what I say next doesn't make sense.

It's important to note that accumulation happens by walking up the tree, on each heading. headingreset marks that element as a point to stop the accumulation of offsets. Your example doesn't demonstrate the subtlety because no elements within the headingreset container have a headingoffset, so the accumulated offset is always 0.

The issue of the name being a misnomer seems to be specific to the implementation, no? From that perspective, yes you're climbing up the tree for the accumulation of the level offsets to find the final level offset to use, and while you're stopping that process when you encounter headingreset, it would be more accurate to say you're also climbing up the tree to find the root offset for the accumulated level offset so far, and only stopping at headingreset because you know there's no more meaningful offsets to accumulate. In this context "reset" isn't an instruction for implementation (that would be "stop" in this case), it's an instruction from the author's perspective with no consideration for the implementation details, which is top-down (as is the precedent in HTML), so I don't believe headingreset would be a misnomer for naming it in HTML.

If this was implemented by climbing down the tree, it would be an instruction to "ignore the accumulated offset so far, use the root level, then carry on accumulating" (which would be less efficient currently), for which headingreset makes fine sense.

If an auto value (which I suspect would need a more appropriate name, as there seems to be 2 possible ideas of what that could do) was to ever be implemented as desirable, I would think it would become more efficient for accumulation to be done by walking down the tree, creating new counters for the level from accumulated offsets so far (e.g. level=level+headingoffset) and the last heading level (initially the same as the level) whenever an element with headingoffset or headingreset is encountered, but with headingreset resetting the level instead (e.g. level=0+headingoffset), so I don't think concern about naming from an implementation perspective when that specific detail may need to change in the future is warranted either.

Having said all that:

  • +1 for relative/cumulative values without negatives, as this seems to cover the most common use cases and makes it less easy to create broken outlines by mistake, while the example usage with headingreset allows for absolute if required.
  • +1 for headingoffset, as this conveys the correct meaning of the value better than other choices.
  • +1 for headingreset, headingunset, or anything else that conveys that the ancestral level (from accumulated offsets) is ignored/reset for its descendants. I do think it might be worth being more verbose with headingoffsetreset, especially if level is not being used in any of the naming, otherwise headinglevelreset would work too.
    • headingrevert is potentially confusing; revert to what?
    • headingroot and headinginitial suggest they are for setting a new absolute root/initial level (like headingstart would).
    • headingstop, headinghalt, headingbreak, etc. depend on an implementation specific detail, so would have no or an alternative meaning to most authors.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
accessibility Affects accessibility addition/proposal New features or enhancements
Development

No branches or pull requests