@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.
- package/LICENSE +21 -0
- package/content/accessibility.mdx +117 -0
- package/content/angular.mdx +60 -0
- package/content/browser-support.mdx +83 -0
- package/content/building-on-the-headless-tier.mdx +143 -0
- package/content/charting.mdx +93 -0
- package/content/coding-agents.mdx +139 -0
- package/content/color-modes.mdx +87 -0
- package/content/color.mdx +92 -0
- package/content/composition.mdx +171 -0
- package/content/elevation-and-material.mdx +93 -0
- package/content/events.mdx +125 -0
- package/content/forms.mdx +170 -0
- package/content/iconography.mdx +80 -0
- package/content/installation.mdx +125 -0
- package/content/intent-reference.mdx +107 -0
- package/content/introduction.mdx +69 -0
- package/content/localization.mdx +95 -0
- package/content/metrics.mdx +399 -0
- package/content/motion.mdx +91 -0
- package/content/other-frameworks.mdx +70 -0
- package/content/overlays.mdx +102 -0
- package/content/overriding-a-component.mdx +160 -0
- package/content/react.mdx +144 -0
- package/content/recipes.mdx +131 -0
- package/content/routing.mdx +208 -0
- package/content/sandbox.mdx +76 -0
- package/content/server-rendering.mdx +103 -0
- package/content/shape-and-borders.mdx +80 -0
- package/content/size-and-density.mdx +86 -0
- package/content/space-and-size.mdx +89 -0
- package/content/state-and-control.mdx +132 -0
- package/content/surfaces-and-separation.mdx +99 -0
- package/content/svelte.mdx +45 -0
- package/content/theme-builder.mdx +66 -0
- package/content/theming.mdx +111 -0
- package/content/tiers.mdx +159 -0
- package/content/tokens.mdx +88 -0
- package/content/typography.mdx +117 -0
- package/content/using-helix-with-an-agent.mdx +231 -0
- package/content/using-the-elements.mdx +125 -0
- package/content/versioning.mdx +72 -0
- package/content/visual-axes.mdx +75 -0
- package/content/vue.mdx +80 -0
- package/content/without-a-bundler.mdx +119 -0
- package/dist/catalog.d.ts +186 -0
- package/dist/catalog.js +296 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/naming.d.ts +14 -0
- package/dist/naming.js +28 -0
- package/dist/pages.generated.d.ts +7 -0
- package/dist/pages.generated.js +284 -0
- package/dist/reference.d.ts +105 -0
- package/dist/reference.js +181 -0
- package/dist/registry.d.ts +89 -0
- package/dist/registry.js +70 -0
- package/dist/sections.d.ts +44 -0
- package/dist/sections.js +36 -0
- package/dist/sources.generated.d.ts +6 -0
- package/dist/sources.generated.js +5158 -0
- 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.
|