Skip to content

Tabs

The tabs are links to their panels. Without JavaScript the row is a table of contents, and the panels follow one after another below it. With JavaScript the component turns the links into tabs: Tab skips past the whole row and into the selected panel, the arrow keys move between the tabs, and the screen reader says “tab, 2 of 3, selected” where it would otherwise have said “link”.

Tabs fit when the first part matters most to most people, and nobody needs everything at once. If the content is to be read in order, or compared part by part, use an accordion instead: there several panels can be open, and name gives you one at a time when that is the point. Tabs hide everything except the selected one, also for someone printing the page.

The first tab is selected when the page loads, with two exceptions: the address points to a panel or something inside it, or the server has written the selection in the markup. The component reads in this order:

  1. aria-selected="true" on a tab, as lt.tabs() from the React entry point writes it. The address then selects no tab, and if it points to another tab in the row, it is corrected to the selected one.
  2. The address, when it points to a panel or something inside it.
  3. The first panel without hidden. The server can therefore select a tab in markup without roles by writing hidden="until-found" on the other panels.
  4. Otherwise the first.

What the component itself wrote does not count as the markup’s answer: if the server switches tab by moving hidden alone, the highlight follows.

The plain lt.tabs() has no selected. A different starting point comes from the address or from the server’s hidden, and afterwards from the app through selected and select() on the element:

const tabs = document.querySelector("lt-tabs")
if (!tabs) throw new Error("element not found")
tabs.selected // 0
tabs.select(1) // selects "Attachments" and sends `tab-select`
tabs.selected = 2 // the same as select(2)

The component has no selected attribute. The answer is in the markup, as aria-selected on the tab, and is read from there. Two places to keep in sync is one too many.

The panel is found by the id in href, and aria-controls is written from it. The panels can therefore be in a different order than the tabs, and outside <lt-tabs>. A panel outside is still not in what the component observes, so if an update tears hidden off it, it does not come back by itself. If a link points to an id that does not exist, the tab can be selected without anything being shown, and the component warns in the console.

tab-select is sent when the user or the component changes the selection: click, arrow keys, select(), the address and find in page. It is not sent when the server moves the selection in an update, so the app does not react to its own echo, and not when the tab being selected is already selected.

Every selection is written to the address, like #case-attachments, without a new history entry. A shared link therefore opens the tab the reader was on, and a link to something inside a panel, like #attachments-list, selects the tab and scrolls there. That address is not rewritten, so the link into the content is not lost. If the address changes while the page is open, the tab is selected.

Find in page finds text in the panels that are hidden, because they are hidden with hidden="until-found". If the search hits a hidden panel, the component selects that tab, and only that panel is shown. The search does not change the address.

If the address cannot be written, as in Safari after many quick selections or in an <iframe srcdoc>, the tab is selected anyway.

Two limitations follow from there being only one address:

  • Two tab lists on the same page share it. A selection in one overwrites the address of the other, and only the last can be restored from a shared link.
  • An app that routes on the fragment, like #/case/1, gets the address overwritten by the tabs. Then server-controlled belongs with it, see below: there the address selects no tab, and the component does not write it either.

Why the panels are hidden with hidden="until-found"

Section titled “Why the panels are hidden with hidden="until-found"”

The HTML standard says that hidden should not be used to hide the panels in a tab list, and that a visible element should not link to a hidden one. LO-TEK departs from that, on purpose. until-found was made precisely so that links to fragments and find in page can reveal content that has been put away, and that is what a tab list does: the content exists, the reader just has not asked for it yet. With a class and display: none instead, find in page would not find the text, and the server could no longer select a tab with hidden.

A panel hidden with until-found keeps its box, so the stylesheet zeroes the space around it. The content of a hidden panel cannot get focus, and the panel itself has no tab stop.

The panels are on the page before defineLtTabs() has run. The plain lt.tabs() does not write hidden, so without JavaScript all the panels are shown one below the other, as a table of contents should be. With JavaScript, tabs.css shows only one panel until the component is registered, the one the component is going to select, so the content does not jump:

  • If the server wrote hidden itself, that applies.
  • Otherwise the panel the address points to, or the panel with that inside it. That is also where a link in the row leads when the component is never registered.
  • Otherwise the first.

Under server-controlled the address does not select, and the first visible panel is shown. The rule only reaches the panels inside <lt-tabs>: a panel outside must have hidden from the server. And it cannot connect aria-selected on a tab to its panel, so markup with aria-selected, as from the React entry point, must also have hidden on the panels that are not selected. The React entry point writes both.

See Markup and behaviour.

With server-controlled on <lt-tabs> the server decides which tab is selected. The component does not put the user’s choice back after an update from the server, but it still does the rest:

  • It writes out what the markup says: aria-selected, the tab stop and hidden in step. The server can therefore move the tab by moving hidden alone.
  • A click selects the tab at once, sends tab-select and writes the tab to the address. The selection stands until the server answers, and then the answer applies.
  • Find in page and a link to something in a hidden panel select the tab the same way as a click.
  • The address selects no tab, neither when the page loads nor when the address changes. It is the server that knows which tab applies.
  • Before the component is registered, tabs.css shows the first panel that is not hidden, and not the one the address points to.

If the attribute is set while the page is open, what is in the markup is the starting point, and a choice the user made before is forgotten.

AttributeTypeEffect
activationautomatic or manualWhat the arrow keys do. With automatic, the default, they select the tab at once. With manual they only move focus, and Enter or Space selects. That fits when the content of a panel takes time to load.
server-controlledbooleanThe server decides which tab is selected. See When the server decides the tab.
ClassWhat it is
.lt-tabs__listThe list of links. Gets role="tablist" from the component, or from lt.tabs() in the React entry point
.lt-tabs__panelOne panel. Those that are not selected have hidden="until-found"

The links have no class of their own; they are styled as .lt-tabs__list a. The selected one has aria-selected="true", and a disabled tab has aria-disabled="true".

VariableFallback
--lt-tabs-gapvar(--lt-spacing-1)
--lt-tabs-paddingvar(--lt-spacing-2) var(--lt-spacing-4)
--lt-tabs-panel-spacevar(--lt-spacing-4)
.lt-tabs__list {
--lt-tabs-gap: 0;
}

The tabs are role="tab" in a role="tablist", the list items around them are role="presentation" so the screen reader does not count them as a list, and the panels are role="tabpanel". If you write them with lt.tabs() from the React entry point, the roles are in the HTML before the script has run. Otherwise the component sets them when it is registered, and puts them back if an update removes them.

The panel gets its name from the heading in it, when it says the same as the tab. If it says something else, or does not exist, the panel gets the tab’s name as aria-label. A connection the server wrote itself, aria-label or aria-labelledby, the component never touches.

The row must have a name: aria-label on .lt-tabs__list, or aria-labelledby pointing at a heading. label is therefore required in lt.tabs(), and the component warns in the console when the name is missing. Without it the screen reader just says “tab list”.

KeyDoes
Arrow keysMove between the tabs, and wrap around from last to first. With automatic they select the tab at once
Home and EndFirst and last tab
Enter and SpaceSelect the tab that has focus, when activation="manual"
TabOut of the row and into the selected panel. Nothing in a panel that is not selected can be reached with Tab

Right and left arrow move in a horizontal row. If the list has aria-orientation="vertical", it is up and down arrow, and in a horizontal row those scroll the page as usual. On a page with dir="rtl" left and right arrow swap direction, so the next tab is always the one next to it in the reading direction. A key with Alt, Ctrl or Cmd held down belongs to the browser: Cmd-click and middle-click on a tab open the panel in a new browser tab, like a link.

A tab with aria-disabled="true" is skipped, and cannot be selected. One tab has tabindex="0", normally the selected one. If the selected one is disabled, the tab stop moves to the next enabled one. The selected panel has tabindex="0", so it gets focus also when it has nothing focusable inside, otherwise Tab skips straight past the content that was just shown. The hidden panels do not have it.

Without JavaScript the row is a plain list of links, without tab roles, and every link can be reached with Tab. The stylesheet gives them a target size of at least 24 pixels.