HelixOverviewComponentsMapsGraphIntroductionInstallationDeriving a themeSandboxTheme builderVisual testingCortexDocsEngramBuildingTraceBuildingRecallDeclaredServicesInsightsGet in touchSource on GitHub
Essentials
Routing and navigationSome components navigate, such as a tree used as a sidebar, a button that opens
another page, or a tab that is a page. Each of these should be a link. A
framework router does not handle a link inside a shadow root the way it handles
its own links.A component that navigates renders its own anchorTree.Item and Button both take an href. With an href, the element you
press is an <a>:<helix-tree-itemhref="/design/components/button"value="button">
Button
</helix-tree-item>A real anchor gives you behavior that JavaScript cannot reproduce:The whole row is the target, including the space around the words.Enter works, because Enter activates a focused anchor.Middle-click and Command-click open a new tab.The destination appears in the status bar on hover.Right-click offers Copy Link Address.A crawler follows it.A <Link> inside each row gives you none of these. In our docs sidebar, the
anchor was 57px of a 240px row, so about three quarters of every row did nothing. Enter
did nothing at all, because the tree reported a selection and no anchor
received it.href and a framework's <Link>You cannot give a component's internal anchor to next/link. <Link> renders
its own<a> and attaches its own handlers to it. An element that renders an
anchor inside a shadow root cannot receive those handlers.Choose one: an href on the component, or a <Link>.Do not wrap a component in a <Link>Wrapping a component in a <Link> only partly works:{/* Don't. */}<Linkhref="/design/components/button"><helix-tree-itemvalue="button">Button</helix-tree-item></Link>The tree still finds its items, because it collects them with a descendant
query. The wrapper causes three problems:Two focus stops per row. The anchor can take focus, and so can the
treeitem inside it. A tree has a single roving tab stop, and the anchor adds a
second one that the tree does not control.A treeitem nested inside a link. Anything that reads the semantics
finds both roles, in that order, for one item.Invalid markup for a branch. A branch contains its children, so wrapping a
branch puts every descendant anchor inside an ancestor anchor. Nested anchors
are invalid, and browsers rearrange them.Some roles cannot contain a linkIn Helix, treeitem and tab are the two roles that cannot contain a link. You
cannot nest an anchor in either role. A tree keeps the anchor and changes its
markup.For a tree, the role moves onto the anchor, and the group becomes the
anchor's sibling:<!-- What Tree.Item renders when it has an href --><divpart="item"role="none"><aaria-expanded="true"href="/x"part="row"role="treeitem">Name</a><divpart="group"role="group">…</div></div>This markup is the navigation form of the tree pattern. For that reason, href
changes the structure of the markup as well as adding an attribute. Without an
href, nothing moves. The wrapper stays the treeitem and contains its group,
the same way an <li> contains a <ul>.Tabs have no equivalent published pattern. Web Awesome has the same problem. Its
answer is to drop the anchor and navigate in JavaScript. That works, and you
lose every item in the list above.Use client-side routing with real anchorsA real anchor causes a full page load, because a framework intercepts only the
anchors it rendered itself. To use the framework's router, add it on top of the
anchors:'use client'constrouter=useRouter()constnavigate=(event)=>{// A modified or middle press is exactly the gesture that wants the browser's// own behavior. Taking it back is taking back the reason for real anchors.if(event.button!==0||event.metaKey||event.ctrlKey||event.shiftKey||event.altKey){return}// The anchor is inside a shadow root, so by the time the event gets here it// has retargeted to the host: `event.target` is the custom element.constlink=event.nativeEvent.composedPath().find((node)=>nodeinstanceofHTMLAnchorElement)if(!link||link.target!==''||newURL(link.href).origin!==location.origin){return}event.preventDefault()router.push(newURL(link.href).pathname)}return<navonClick={navigate}onPointerOver={warm}>{/* … */}</nav>One listener on the container handles every row. You do not need to add
listeners again as rows are added and removed.Prefetch when the pointer or focus reaches a link<Link> prefetches when a link enters the viewport. In a sidebar of seventy
links, that is seventy requests for one navigation. Prefetch on pointerover
and focus to make one request. People point at a row well before they press
it:constwarmed=useRef(newSet())constwarm=(event)=>{constpath=pathOf(event)if(path&&!warmed.current.has(path)){warmed.current.add(path)router.prefetch(path)}}Next prefetches only in production, so this code has no effect in development.
That is expected.Components that take an hrefComponentNotesButtonRenders an <a> styled as a button.Tree.ItemChanges the ARIA shape, as above. A linked branch navigates from its row and opens from its chevron.Menu.ItemThe row becomes the anchor and carries menuitem. Takes target, rel and download.Other components that should navigate do not take an href yet. To make one
navigate, slot a link into it. Tree.Item follows a slotted anchor when you
press anywhere in the row or use the keyboard, so a tree item can navigate
without an href.Option cannot take an hrefA command palette is a common place to want links in a list, because its
entries are often half commands and half destinations. It is the one place
where Helix cannot offer an href.A listbox cannot move focus to its options. A combobox keeps focus in its text
field while the arrow keys move through the list, so it has to use
aria-activedescendant. Focus stays in the field, and an attribute marks the
current option. A menu works the other way, which makes Menu.Item simple.
Focus moves to the row, so the row can be an anchor with menuitem on it.The aria-activedescendant reference reaches the option's host and nothing
inside it. IDREFs do not cross shadow boundaries, so Listbox uses the
element-reference form. We measured this in Chromium. Assigning
ariaActiveDescendantElement an element inside a sibling shadow root reads
back null. Assigning the option's host reads back the host. So
role="option" stays on the host, because the host is the only element the
listbox can point at.A role="option" with an anchor inside it is also broken. In the same browser,
the accessibility tree for <div role="option"><a href>Open settings</a></div> inside a listbox shows an option with no accessible name,
with the link as its child. A sibling option with plain text has the correct
name. Moving the role onto the anchor works for treeitem and menuitem, and
it cannot work here.No arrangement of the markup meets both of these constraints.In a command palette, navigate from the helix-select event. Listen for
helix-select on helix-command and call your router. A palette entry is a
command, and "go to Settings" is a command that navigates. Command palettes in
other applications work the same way. You lose the gestures that only a real
anchor has:middle-clicka modified clickthe destination in the status bar