@fusion.dev/helix-docs 0.0.0-dev

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/content/accessibility.mdx +117 -0
  3. package/content/angular.mdx +60 -0
  4. package/content/browser-support.mdx +83 -0
  5. package/content/building-on-the-headless-tier.mdx +143 -0
  6. package/content/charting.mdx +93 -0
  7. package/content/coding-agents.mdx +139 -0
  8. package/content/color-modes.mdx +87 -0
  9. package/content/color.mdx +92 -0
  10. package/content/composition.mdx +171 -0
  11. package/content/elevation-and-material.mdx +93 -0
  12. package/content/events.mdx +125 -0
  13. package/content/forms.mdx +170 -0
  14. package/content/iconography.mdx +80 -0
  15. package/content/installation.mdx +125 -0
  16. package/content/intent-reference.mdx +107 -0
  17. package/content/introduction.mdx +69 -0
  18. package/content/localization.mdx +95 -0
  19. package/content/metrics.mdx +399 -0
  20. package/content/motion.mdx +91 -0
  21. package/content/other-frameworks.mdx +70 -0
  22. package/content/overlays.mdx +102 -0
  23. package/content/overriding-a-component.mdx +160 -0
  24. package/content/react.mdx +144 -0
  25. package/content/recipes.mdx +131 -0
  26. package/content/routing.mdx +208 -0
  27. package/content/sandbox.mdx +76 -0
  28. package/content/server-rendering.mdx +103 -0
  29. package/content/shape-and-borders.mdx +80 -0
  30. package/content/size-and-density.mdx +86 -0
  31. package/content/space-and-size.mdx +89 -0
  32. package/content/state-and-control.mdx +132 -0
  33. package/content/surfaces-and-separation.mdx +99 -0
  34. package/content/svelte.mdx +45 -0
  35. package/content/theme-builder.mdx +66 -0
  36. package/content/theming.mdx +111 -0
  37. package/content/tiers.mdx +159 -0
  38. package/content/tokens.mdx +88 -0
  39. package/content/typography.mdx +117 -0
  40. package/content/using-helix-with-an-agent.mdx +231 -0
  41. package/content/using-the-elements.mdx +125 -0
  42. package/content/versioning.mdx +72 -0
  43. package/content/visual-axes.mdx +75 -0
  44. package/content/vue.mdx +80 -0
  45. package/content/without-a-bundler.mdx +119 -0
  46. package/dist/catalog.d.ts +186 -0
  47. package/dist/catalog.js +296 -0
  48. package/dist/index.d.ts +7 -0
  49. package/dist/index.js +6 -0
  50. package/dist/naming.d.ts +14 -0
  51. package/dist/naming.js +28 -0
  52. package/dist/pages.generated.d.ts +7 -0
  53. package/dist/pages.generated.js +284 -0
  54. package/dist/reference.d.ts +105 -0
  55. package/dist/reference.js +181 -0
  56. package/dist/registry.d.ts +89 -0
  57. package/dist/registry.js +70 -0
  58. package/dist/sections.d.ts +44 -0
  59. package/dist/sections.js +36 -0
  60. package/dist/sources.generated.d.ts +6 -0
  61. package/dist/sources.generated.js +5158 -0
  62. package/package.json +46 -0
@@ -0,0 +1,160 @@
1
+ ---
2
+ description: The three ways in past a shadow root, and which one to reach for.
3
+ section: Theming
4
+ title: Overriding a component
5
+ ---
6
+
7
+ # Overriding a component
8
+
9
+ Every Helix component draws inside a shadow root, so your stylesheet cannot
10
+ reach in. That is the point: a rule you write can never accidentally break a
11
+ component's internals, and a component can be rewritten without breaking your
12
+ page.
13
+
14
+ Three doors are open, and they are in the order you should try them.
15
+
16
+ ## 1. Tokens
17
+
18
+ ```css
19
+ :root {
20
+ --helix-radii-md: 2px;
21
+ }
22
+ ```
23
+
24
+ If every card, dialog and button should be squarer, that is a token change and
25
+ you are done. Tokens cross shadow boundaries on their own, because custom
26
+ properties inherit, so one declaration reaches every component at once.
27
+
28
+ Reach for this first. Most requests that arrive as "can I restyle the button"
29
+ are really "our brand has less rounding than yours", and the token is both the
30
+ smaller change and the one that stays consistent.
31
+
32
+ See [Tokens](./tokens) and [Deriving a theme](./theming).
33
+
34
+ ## 2. Per-component hooks
35
+
36
+ ```css
37
+ helix-card {
38
+ --card-padding: var(--helix-space-lg);
39
+ }
40
+
41
+ helix-accordion {
42
+ --accordion-border-color: var(--helix-color-border);
43
+ }
44
+ ```
45
+
46
+ A hook is a custom property a component publishes and reads. It defaults to a
47
+ semantic token, so leaving it alone gets you the themed value, and setting it
48
+ changes that component and nothing else.
49
+
50
+ **They are API, not tokens**, and the distinction is enforced. A hook is named
51
+ after the component (`--card-padding`, `--drawer-radius`) and a token is named
52
+ `--helix-*`. Every documented hook is held to a rule that actually reads it, in
53
+ both directions: declared and never read is a promise with nothing behind it,
54
+ and read and never declared is a working feature nobody is told about. Three of
55
+ `Button`'s four hooks were inert for a refactor's worth of time while the
56
+ reference went on listing them, which is how that check came
57
+ to exist.
58
+
59
+ Each component's page lists its hooks under Custom properties.
60
+
61
+ ## 3. Parts
62
+
63
+ ```css
64
+ helix-select::part(trigger) {
65
+ font-variant-numeric: tabular-nums;
66
+ }
67
+
68
+ helix-dialog::part(dialog) {
69
+ max-inline-size: 40rem;
70
+ }
71
+ ```
72
+
73
+ `::part()` reaches a named element inside the shadow root and lets you write any
74
+ declaration you like on it. It is the most powerful door and the one to try
75
+ last, because it is the one that couples your stylesheet to a component's
76
+ internal structure.
77
+
78
+ **A published part is a promise**, held both ways:
79
+ a documented part that nothing renders, and a rendered part nobody was told
80
+ about. The second is the worse of the two, because a `::part()` that resolves to
81
+ nothing is valid CSS that matches nothing and reports nothing. `Breadcrumbs`
82
+ published a `nav` part the family does not render; `Checkbox` renders the
83
+ `field` part the whole styled appearance is built on and never declared it.
84
+
85
+ Each component's page lists its parts.
86
+
87
+ ### A composed component forwards what it renders
88
+
89
+ `::part()` reaches one shadow boundary and stops, so a component that renders
90
+ another would hide that child's whole part surface. Every one of them forwards
91
+ it instead, and the forwarded name is prefixed with the role the child plays:
92
+
93
+ ```css
94
+ helix-date-input::part(picker-cell) { … }
95
+ helix-select::part(listbox-items) { … }
96
+ helix-video::part(overflow-header) { … }
97
+ ```
98
+
99
+ `<helix-date-input>` renders a date picker as its `picker`, so the picker's
100
+ `cell` is `picker-cell` here. Where the child's part is already the role, the
101
+ role stands alone: the tooltip inside `<helix-copy-button>` is `tooltip`, and
102
+ its arrow is `tooltip-arrow`.
103
+
104
+ **The prefix is not decoration.** Two levels can agree on a word, and did:
105
+ `Command` forwarded the listbox's `search-field` under a name it also rendered
106
+ itself, so `::part(search-field)` matched two elements at once.
107
+
108
+ This is checked in both directions. `check:parts` holds a published part and a
109
+ rendered one to each other, and `check:exportparts` holds a composition to
110
+ forwarding everything its child publishes, under one prefix, colliding with
111
+ nothing. Before it existed, 148 published child parts were unreachable: all
112
+ sixteen of the color picker's from inside a color input, all thirteen of the
113
+ date picker's from inside a date input.
114
+
115
+ If you still find a part you cannot reach, that is worth reporting rather than
116
+ working around: the missing part is the bug.
117
+
118
+ ### Words that name a position mean one thing
119
+
120
+ A part that names a *role* rather than a thing draws from a fixed vocabulary, so
121
+ you learn it once instead of per component:
122
+
123
+ | Part | What it is |
124
+ |---|---|
125
+ | `box` | The drawn control, where the host is not it. |
126
+ | `container` | An element's own outer box. |
127
+ | `content` | A region you fill. |
128
+ | `panel` | A region switched between, or a sheet anchored to an edge. |
129
+ | `surface` | A floating panel drawn over the page. |
130
+ | `viewport` | A scrollport the component reads the position of. |
131
+
132
+ Anything naming a thing the component actually has, a `thumb`, a `swatch`, a
133
+ `weekday`, is that component's own word and is on its page.
134
+
135
+ ## What not to do
136
+
137
+ **Do not raise specificity to fight a component.** `helix-button.thing.thing` is
138
+ still outside the shadow root, so it wins nothing it was not already winning.
139
+ Selector weight is not what is stopping you.
140
+
141
+ **Do not restyle through `!important` on the host.** Some properties inherit
142
+ into a shadow root and most do not, so this works for `color` and `font-family`
143
+ and fails silently for everything else, which is a worse outcome than failing
144
+ loudly.
145
+
146
+ **Do not fork the component to change a color.** The headless tier exists for
147
+ the case where you want none of our styling, and it is a different decision from
148
+ overriding one value. See [Tiers](./tiers).
149
+
150
+ ## When you want all of it
151
+
152
+ ```ts
153
+ import '@fusion.dev/helix-headless'
154
+ ```
155
+
156
+ The headless tier is structure, accessibility, state and form participation with
157
+ no CSS at all. You write the entire appearance. That is the honest answer when
158
+ the design is genuinely not ours, and it is a supported tier rather than an
159
+ escape hatch: `helix-headless-select` is a registered element, not an abstract
160
+ class.
@@ -0,0 +1,144 @@
1
+ ---
2
+ description: The typings, the hydration bridge, and how state moves.
3
+ parent: installation
4
+ section: Getting started
5
+ title: React
6
+ ---
7
+
8
+ # React
9
+
10
+ There is no React package. The elements are custom elements and React renders
11
+ them directly. What ships for React is a `jsx.d.ts` that types every tag and a
12
+ runtime bridge in `@fusion.dev/helix-core` that repairs two React behaviors.
13
+
14
+ ```ts
15
+ import '@fusion.dev/helix/jsx'
16
+ ```
17
+
18
+ A types-only import, once, anywhere TypeScript will see it. After that
19
+ `<helix-button>` is a typed element with its real props, and a value the
20
+ component does not accept is a compile error rather than a runtime shrug.
21
+
22
+ ## Bind events with `onhelix-*`
23
+
24
+ ```tsx
25
+ <helix-accordion-item onhelix-before-open={(event) => track(event.detail.open)} />
26
+ ```
27
+
28
+ The prop is `on` and the event name, verbatim. Not `onHelixOpen`, not `onOpen`.
29
+ React 19 forwards an unknown `on*` prop to `addEventListener` using the name as
30
+ written, so the casing is the event's own.
31
+
32
+ ## The bridge
33
+
34
+ React skips everything it would have to apply imperatively while hydrating. That
35
+ is two things, and `HelixElement` repairs both.
36
+
37
+ **Event handlers** attached during hydration are stashed on the DOM node but
38
+ never bound, so an event fired before the next render finds nothing listening.
39
+ The element reads React's own stash off the node and binds what it finds, so a
40
+ handler works from the first paint rather than from the first re-render.
41
+
42
+ **Object and function properties** are dropped on both sides: React's server
43
+ renderer writes nothing for a prop whose value is an object, and the hydrate path
44
+ never applies one. An array prop therefore arrives by no route at all, and
45
+ because both halves agree, nothing warns. The element assigns them itself once it
46
+ upgrades, which is what lets a data table take its rows as a plain prop:
47
+
48
+ ```tsx
49
+ <helix-data-table label="People" rows={rows}>
50
+ <helix-table-column field="name" sortable>Name</helix-table-column>
51
+ <helix-table-column field="team" sortable>Team</helix-table-column>
52
+ <helix-table-column field="location">Location</helix-table-column>
53
+ </helix-data-table>
54
+ ```
55
+
56
+ Only properties the component declares, and only values an attribute could not
57
+ have carried, so a string is left to the channel that already works. A `null` is
58
+ left alone too, and the component's own default stands.
59
+
60
+ The columns are elements rather than a config array for the same reason: `field`,
61
+ `sortable` and `width` are strings and booleans, so they cross to the server
62
+ intact, and `cell` and `compare` are functions on an element that can repair its
63
+ own props. Nested inside a `columns` array they would be unreachable.
64
+
65
+ **The cell values still do not server-render**, which is a separate limit and not
66
+ this one. Reading a cell needs the columns, and the columns are markup children
67
+ the server render does not hand the table, so the first paint is a labeled shell
68
+ either way. Use `helix-table` where the content has to be in the served HTML.
69
+
70
+ That is runtime code you already have, in `@fusion.dev/helix-core`, and nothing to
71
+ configure. One thing to know about it: it reads a React
72
+ internal, the per-page key React stores props under. If a React release renames
73
+ that key the bridge stops repairing and your handlers go back to needing a
74
+ commit before they fire. Helix pins the React version it was measured against
75
+ and fails its own build when React moves past it, so this cannot go unnoticed.
76
+
77
+ ## A declared property is a claim of ownership
78
+
79
+ This is the one thing about Helix that surprises people coming from React, and
80
+ it is a compile error rather than a surprise at runtime:
81
+
82
+ ```tsx
83
+ // Does not compile.
84
+ <helix-input label="Name" value="Ada" />
85
+ ```
86
+
87
+ In React, `value` on a native `<input>` without `onChange` renders a read-only
88
+ field and warns. **A Helix element is not read-only**: `value` is a seed and the
89
+ element goes on applying its own changes, so the markup below works and needs no
90
+ handler beside it.
91
+
92
+ ```tsx
93
+ // Seeded: the element owns it from there.
94
+ <helix-input default-value="Ada" label="Name" />
95
+
96
+ // Mirrored: bind the notification and let the element apply.
97
+ <helix-input label="Name" onChange={(event) => setName(event.currentTarget.value)} />
98
+
99
+ // Refused: you take the decision for this one change.
100
+ <helix-input
101
+ label="Name"
102
+ onhelix-before-change={(event) => {
103
+ event.preventDefault()
104
+ setName(clean(event.detail.value))
105
+ }}
106
+ value={name}
107
+ />
108
+ ```
109
+
110
+ **The one habit to unlearn is refusing by not answering.** React force-restores
111
+ a native `<input>` whose state did not move and does not do this for a custom
112
+ element, so a handler that declines to call `setState` leaves the element
113
+ holding the new value. `preventDefault()` is the refusal.
114
+
115
+ *Until 26 Aug 2026 this page described the opposite, and the typings enforced
116
+ it: writing `value` was a claim of ownership, the element applied nothing until
117
+ told, and `<helix-input value="Ada" />` was a compile error without an answering
118
+ handler. The constraint is deleted along with the mode.*
119
+
120
+ See [State and control](./state-and-control) for the whole of it.
121
+
122
+ ## Register in a client component
123
+
124
+ ```tsx
125
+ 'use client'
126
+ import '@fusion.dev/helix'
127
+
128
+ export const HelixElements = () => null
129
+ ```
130
+
131
+ Render it once in your layout. **Your pages stay server components.** An
132
+ unregistered tag is an unknown element in the HTML that upgrades when the
133
+ definition lands, so nothing about a server component needs to change.
134
+
135
+ ## There is no declarative shadow DOM
136
+
137
+ A server component's output is replayed from the flight payload through DOM APIs
138
+ rather than parsed as HTML, and declarative shadow DOM cannot activate for
139
+ script-inserted nodes. This is why `elements.css` exists: it styles the
140
+ un-upgraded markup so the interval before hydration looks like the finished page
141
+ rather than like unstyled boxes.
142
+
143
+ [Server rendering](./server-rendering) has the whole contract, and
144
+ [Using the elements](./using-the-elements) has the rest of the binding rules.
@@ -0,0 +1,131 @@
1
+ ---
2
+ description: Helix styling for markup this library does not render, with no JavaScript.
3
+ section: Foundations
4
+ title: Recipes
5
+ ---
6
+
7
+ # Recipes
8
+
9
+ `@fusion.dev/helix-styles` carries recipes for markup the components do not render: your
10
+ own anchors, your own buttons, a card you built yourself, prose out of a CMS.
11
+ They are `data-` attributes, they need no JavaScript, and they read the same
12
+ tokens the components do.
13
+
14
+ This is what lets a page look like Helix without a single custom element on it.
15
+
16
+ ## Buttons
17
+
18
+ ```html
19
+ <button
20
+ data-button="solid"
21
+ data-button-tone="primary"
22
+ >Save</button>
23
+ <a
24
+ data-button="ghost"
25
+ href="/settings"
26
+ >Settings</a>
27
+ ```
28
+
29
+ Two axes: `data-button` is how much ink it claims (`solid`, `outlined`, `ghost`,
30
+ `subtle`) and `data-button-tone` is what it means (`primary`, `accent`,
31
+ `neutral`, `danger`). `data-button-width` takes `auto`, `standard` or `full`.
32
+
33
+ **This exists for elements this library does not render**, which is the case an
34
+ `<a>` styled as a button falls into. When you want a button, use `helix-button`:
35
+ the recipe has no loading state, no pressed state and no `type="submit"`
36
+ handling.
37
+
38
+ ## Links
39
+
40
+ ```html
41
+ <a
42
+ data-link
43
+ href="/pricing"
44
+ >Pricing</a>
45
+ <a
46
+ data-link
47
+ data-link-underline="quiet"
48
+ href="/docs"
49
+ >Docs</a>
50
+ ```
51
+
52
+ `data-link-size` and `data-link-underline` are the modifiers. `quiet` is for
53
+ navigation, where a line under every row would be noise. A link that opens a new
54
+ context says so on its own, rather than surprising the reader.
55
+
56
+ ## Surfaces and layout
57
+
58
+ ```html
59
+ <div data-card>…</div>
60
+ <li data-list-item>…</li>
61
+ <div data-dock="bottom-end">…</div>
62
+ ```
63
+
64
+ `data-dock` pins something to a corner, which is a fact about the page rather
65
+ than about the control: `data-dock-within` scopes it to the nearest positioned
66
+ ancestor instead of the window.
67
+
68
+ ## Surface axes
69
+
70
+ Two of the [visual axes](./visual-axes) describe a surface rather than a control,
71
+ and these are how a plain element reaches them:
72
+
73
+ ```html
74
+ <section data-material="glass">…</section>
75
+ <div data-separation="elevated">…</div>
76
+ ```
77
+
78
+ `data-material` takes `glass`, `frosted` and `transparent`, and `data-separation`
79
+ takes `elevated`, `filled`, `outlined` and `plain`. The values are the axes' own,
80
+ so one word means the same thing here as it does on a component: a `<section>`
81
+ that is `glass` and a component that is `material="glass"` are asking for the
82
+ same optical treatment. See [Elevation and
83
+ material](./elevation-and-material).
84
+
85
+ **Blur is the expensive one.** `glass` and `frosted` both carry a
86
+ `backdrop-filter`, which makes the compositor re-read what is behind the element
87
+ on every frame it moves, so a full-width band that stays on screen through a
88
+ scroll pays continuously. Where the effect is about depth rather than texture,
89
+ `data-separation` is the cheaper axis.
90
+
91
+ ## Prose
92
+
93
+ ```html
94
+ <article data-prose>
95
+ <h2>From a CMS</h2>
96
+ <p>Styled without being rewritten.</p>
97
+ </article>
98
+ ```
99
+
100
+ Headings take the type scale, paragraphs take the measure and leading, lists and
101
+ links and inline code all follow. See [Typography](./typography).
102
+
103
+ ## Region axes
104
+
105
+ These two are API rather than styling, and they are the reason the base layer is
106
+ worth importing even in an app that uses only components:
107
+
108
+ ```html
109
+ <div data-density="sm">…</div>
110
+ <div data-color-mode="dark">…</div>
111
+ ```
112
+
113
+ `data-density` sets how big the controls in a region are, and it works through
114
+ custom properties so it crosses every shadow boundary on its own.
115
+ `data-color-mode` sets the color mode for a subtree. See [Size and
116
+ density](./size-and-density) and [Color modes](./color-modes).
117
+
118
+ ## Utilities
119
+
120
+ ```html
121
+ <span data-hidden-visually>Sorting by</span>
122
+ ```
123
+
124
+ Clipped rather than `display: none`, so it stays in the accessibility tree. See
125
+ [Accessibility](./accessibility).
126
+
127
+ ## When a recipe is the wrong tool
128
+
129
+ A recipe has appearance and no behavior. It cannot manage state, cannot
130
+ participate in a form, cannot trap focus and has no keyboard handling. If you
131
+ find yourself adding script to a recipe, the component is what you wanted.
@@ -0,0 +1,208 @@
1
+ ---
2
+ description: Why a component renders its own anchor, and how to keep a router.
3
+ section: Essentials
4
+ title: Routing and navigation
5
+ ---
6
+
7
+ # Routing and navigation
8
+
9
+ Some components navigate. A tree used as a sidebar, a button that goes
10
+ somewhere, a tab that is really a page. Those all want to be links, and a link
11
+ inside a shadow root is where web components and framework routers disagree.
12
+
13
+ ## A component that navigates renders its own anchor
14
+
15
+ `Tree.Item` and `Button` both take an `href`, and when they have one the thing
16
+ you press *is* an `<a>`:
17
+
18
+ ```html
19
+ <helix-tree-item
20
+ href="/design/components/button"
21
+ value="button"
22
+ >
23
+ Button
24
+ </helix-tree-item>
25
+ ```
26
+
27
+ That is not a detail of the implementation. What it buys is a list of things no
28
+ amount of JavaScript reproduces:
29
+
30
+ - the whole row is the target, not just the words
31
+ - Enter works, because a focused anchor is a thing Enter activates
32
+ - middle-click and Command-click open a tab
33
+ - the destination appears in the status bar on hover
34
+ - right-click offers Copy Link Address
35
+ - a crawler follows it
36
+
37
+ Before `href` existed, the docs sidebar put a `<Link>` inside each row. The
38
+ anchor was 57px of a 240px row, so five sixths of every row did nothing, and
39
+ Enter did nothing at all: the tree announced a selection and no anchor heard it.
40
+
41
+ ## `href` and a framework's `<Link>`
42
+
43
+ You cannot give a component's internal anchor to `next/link`. `<Link>` renders
44
+ *its own* `<a>` and attaches its own handlers to it, and there is no way to hand
45
+ that to an element that renders one inside a shadow root.
46
+
47
+ So it is one or the other.
48
+
49
+ ## Wrapping the component in a `<Link>` is not the third option
50
+
51
+ It looks like it should work, and it half does:
52
+
53
+ ```tsx
54
+ {/* Don't. */}
55
+ <Link href="/design/components/button">
56
+ <helix-tree-item value="button">Button</helix-tree-item>
57
+ </Link>
58
+ ```
59
+
60
+ The tree still finds its items, because it collects them with a descendant
61
+ query rather than by looking at its children. What it costs:
62
+
63
+ - **Two focus stops per row.** The anchor is focusable and so is the treeitem
64
+ inside it. A tree owns a single roving tab stop, and this adds a second one it
65
+ does not control.
66
+ - **A `treeitem` nested inside a `link`.** Anything reading the semantics meets
67
+ both, in that order, for one thing.
68
+ - **Nothing at all for a branch.** A branch contains its children, so wrapping
69
+ one puts every descendant anchor inside an ancestor anchor. That is invalid,
70
+ and browsers rearrange it.
71
+
72
+ ## Some roles cannot contain a link
73
+
74
+ `treeitem` and `tab` are the two in this library. You cannot nest an anchor in
75
+ either, and the answer is not to give up the anchor.
76
+
77
+ For a tree, the role moves *onto* the anchor and the group becomes its sibling:
78
+
79
+ ```html
80
+ <!-- What Tree.Item renders when it has an href -->
81
+ <div
82
+ part="item"
83
+ role="none"
84
+ >
85
+ <a
86
+ aria-expanded="true"
87
+ href="/x"
88
+ part="row"
89
+ role="treeitem"
90
+ >Name</a>
91
+ <div
92
+ part="group"
93
+ role="group"
94
+ >…</div>
95
+ </div>
96
+ ```
97
+
98
+ That is the navigation flavor of the tree pattern, and it is why `href` changes
99
+ the shape of the markup rather than only adding an attribute. Without an `href`
100
+ nothing moves: the wrapper stays the `treeitem` and holds its group, which is
101
+ what an `<li>` holding a `<ul>` is.
102
+
103
+ Tabs have no equivalent published pattern. Web Awesome hit the same wall and
104
+ answered it the other way, telling consumers to drop the anchor and navigate in
105
+ JavaScript. That works, and it costs every item in the list above.
106
+
107
+ ## Getting client-side routing back
108
+
109
+ Real anchors do a full page load, because a framework only intercepts anchors it
110
+ rendered itself. If you want its router, put it back on top rather than giving
111
+ up the anchors:
112
+
113
+ ```tsx
114
+ 'use client'
115
+ const router = useRouter()
116
+
117
+ const navigate = (event) => {
118
+ // A modified or middle press is exactly the gesture that wants the browser's
119
+ // own behavior. Taking it back is taking back the reason for real anchors.
120
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
121
+ return
122
+ }
123
+ // The anchor is inside a shadow root, so by the time the event gets here it
124
+ // has retargeted to the host: `event.target` is the custom element.
125
+ const link = event.nativeEvent
126
+ .composedPath()
127
+ .find((node) => node instanceof HTMLAnchorElement)
128
+ if (!link || link.target !== '' || new URL(link.href).origin !== location.origin) {
129
+ return
130
+ }
131
+ event.preventDefault()
132
+ router.push(new URL(link.href).pathname)
133
+ }
134
+
135
+ return <nav onClick={navigate} onPointerOver={warm}>{/* … */}</nav>
136
+ ```
137
+
138
+ One listener on the container covers every row, and nothing has to be rebound as
139
+ rows come and go.
140
+
141
+ ### Prefetch on approach, not on sight
142
+
143
+ `<Link>` prefetches when a link enters the viewport. For a sidebar of seventy
144
+ that is seventy requests for one navigation. Prefetching on `pointerover` and
145
+ `focus` instead costs one, and a reader points at a row well before they press
146
+ it:
147
+
148
+ ```tsx
149
+ const warmed = useRef(new Set())
150
+ const warm = (event) => {
151
+ const path = pathOf(event)
152
+ if (path && !warmed.current.has(path)) {
153
+ warmed.current.add(path)
154
+ router.prefetch(path)
155
+ }
156
+ }
157
+ ```
158
+
159
+ Prefetching is production-only in Next, so this does nothing in development. It
160
+ is not broken.
161
+
162
+ ## Which components take an `href` today
163
+
164
+ | Component | Notes |
165
+ |---|---|
166
+ | `Button` | Renders an `<a>` styled as a button. |
167
+ | `Tree.Item` | Changes the ARIA shape, as above. A linked branch navigates from its row and opens from its chevron. |
168
+ | `Menu.Item` | The row becomes the anchor and carries `menuitem`. Takes `target`, `rel` and `download`. |
169
+
170
+ Anything else that should navigate is a gap. Slot a link into it, and press the
171
+ row rather than the words: `Tree.Item` follows a slotted anchor from anywhere in
172
+ the row and from the keyboard, which covers the case without an `href`.
173
+
174
+ ## `Option` cannot take one
175
+
176
+ The obvious place to want a link in a list is a command palette, whose entries
177
+ are usually half commands and half destinations. It is the one place this
178
+ library cannot offer an `href`.
179
+
180
+ **A listbox cannot move focus to its options.** A combobox keeps focus in its
181
+ text field while the arrows walk the list, so the pattern it is obliged to use
182
+ is `aria-activedescendant`: focus stays put and an attribute says which option
183
+ is current. A menu is the opposite, and that is what makes `Menu.Item` easy:
184
+ focus really moves to the row, so the row can be an anchor with `menuitem` on
185
+ it.
186
+
187
+ **That reference reaches the option's host and nothing inside it.** IDREFs do
188
+ not cross shadow boundaries at all, so `Listbox` uses the element-reference
189
+ form. Measured in Chromium: assigning `ariaActiveDescendantElement` an element
190
+ inside a *sibling* shadow root reads back `null`, while assigning the option's
191
+ host reads back the host. So `role="option"` is pinned to the host, since the
192
+ host is the only element the listbox can point at.
193
+
194
+ **And a `role="option"` with an anchor inside it is broken.** Measured in the
195
+ same browser, the accessibility tree for `<div role="option"><a href>Open
196
+ settings</a></div>` inside a listbox is an option with *no accessible name*, and
197
+ the link exposed as a child of it. The sibling option with plain text is named
198
+ correctly. So the manoeuvre that works for `treeitem` and `menuitem`, moving the
199
+ role onto the anchor, is exactly the one thing that cannot be done here.
200
+
201
+ Two constraints pointing opposite ways, and no arrangement satisfies both.
202
+
203
+ **So a palette routes through its event.** Listen for `helix-select` on
204
+ `helix-command` and call the router. That is what a palette is for, entries
205
+ being commands and "go to Settings" being a command that navigates, and it is
206
+ what every palette a reader has used already does. What it costs is the gestures
207
+ only a real anchor has: middle-click, a modified click, and the destination in
208
+ the status bar.