@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,76 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: The editor beside the preview, and how to build one of your own.
|
|
3
|
+
section: Tooling
|
|
4
|
+
title: Sandbox
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Sandbox
|
|
8
|
+
|
|
9
|
+
[Open the sandbox](/helix/sandbox).
|
|
10
|
+
|
|
11
|
+
Every example on a component's page is editable. Change the markup and the
|
|
12
|
+
preview redraws; change the stylesheet beside it and the preview restyles.
|
|
13
|
+
|
|
14
|
+
The same elements are what you would reach for to build a playground of your
|
|
15
|
+
own, and the isolation below is what makes running someone else's markup safe.
|
|
16
|
+
|
|
17
|
+
## The frame is sandboxed
|
|
18
|
+
|
|
19
|
+
The preview runs in a sandboxed iframe with an opaque origin, and the script
|
|
20
|
+
that drives it is served as a static classic script rather than rendered by a
|
|
21
|
+
route.
|
|
22
|
+
|
|
23
|
+
**What that buys:** markup typed into the sandbox runs in the frame, and an
|
|
24
|
+
`<img onerror>` in it cannot read the surrounding page's cookies, its
|
|
25
|
+
`localStorage`, or the editor's contents. Before the frame was isolated, it
|
|
26
|
+
could. Measured.
|
|
27
|
+
|
|
28
|
+
The classic script is the part that looks like packaging and is not: from an
|
|
29
|
+
opaque origin, a module import or a `fetch` back to the site's origin is
|
|
30
|
+
cross-origin and fails without CORS headers. A classic `<script src>` is not
|
|
31
|
+
CORS-checked, so it is the one shape that can carry the component library into a
|
|
32
|
+
document the page cannot reach into and which cannot reach back out.
|
|
33
|
+
|
|
34
|
+
## Coordination is by event
|
|
35
|
+
|
|
36
|
+
A source pane dispatches `helix-before-edit`, which bubbles, and the sandbox hands
|
|
37
|
+
the value to whatever preview is slotted into it. Neither child knows the other
|
|
38
|
+
exists, so either can be replaced or used alone.
|
|
39
|
+
|
|
40
|
+
The event carries the pane's `name`, which is what lets one sandbox hold several
|
|
41
|
+
panes without the root identifying senders by reference: `html` is the preview's
|
|
42
|
+
markup and `css` is its stylesheet. A pane named anything else is showing
|
|
43
|
+
something rather than driving it, and is ignored.
|
|
44
|
+
|
|
45
|
+
```html
|
|
46
|
+
<helix-sandbox>
|
|
47
|
+
<helix-sandbox-source
|
|
48
|
+
name="html"
|
|
49
|
+
slot="source"
|
|
50
|
+
>…</helix-sandbox-source>
|
|
51
|
+
<helix-sandbox-source
|
|
52
|
+
name="css"
|
|
53
|
+
slot="source"
|
|
54
|
+
>…</helix-sandbox-source>
|
|
55
|
+
<helix-sandbox-preview slot="preview"></helix-sandbox-preview>
|
|
56
|
+
</helix-sandbox>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## The console is patched in the frame
|
|
60
|
+
|
|
61
|
+
Logs from the example reach the page's console panel; logs from the page around
|
|
62
|
+
it do not. That is deliberate and is the point of doing the patching in the
|
|
63
|
+
frame rather than in the sandbox: it catches what the example itself logs and
|
|
64
|
+
nothing else.
|
|
65
|
+
|
|
66
|
+
## The package is private
|
|
67
|
+
|
|
68
|
+
`@fusion.dev/helix-sandbox` does not publish. It is the editor and spec runner for this
|
|
69
|
+
repository, and it is Helix tooling rather than a Helix product: the elements
|
|
70
|
+
assume the frame, the frame assumes the build, and the build assumes this
|
|
71
|
+
repository's layout.
|
|
72
|
+
|
|
73
|
+
If you want an editable example in your own documentation, the honest answer
|
|
74
|
+
today is that you would build it, and the manifest and the `.examples` files are
|
|
75
|
+
what you would build it from. That is a gap rather than a policy, and worth
|
|
76
|
+
telling us about.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: What renders without a DOM, and what has to wait for one.
|
|
3
|
+
section: Essentials
|
|
4
|
+
title: Server rendering
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Server rendering
|
|
8
|
+
|
|
9
|
+
Every component renders on a server with no DOM. If you are only consuming
|
|
10
|
+
Helix, the practical consequence is small: components work in your server
|
|
11
|
+
components and there is nothing to configure. If you are extending one, the
|
|
12
|
+
rules below are the whole contract.
|
|
13
|
+
|
|
14
|
+
## There is no DOM
|
|
15
|
+
|
|
16
|
+
Lit's element shim carries attributes. It does not carry a tree. `querySelector`
|
|
17
|
+
is undefined, `this.children` is undefined, and anything that walks the DOM
|
|
18
|
+
throws while rendering.
|
|
19
|
+
|
|
20
|
+
Two real examples from this library, both found the first time their component
|
|
21
|
+
was given a server spec:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
// Select. `display` is read during render and reached the render root.
|
|
25
|
+
private get listbox() {
|
|
26
|
+
return this.renderRoot?.querySelector('[part="listbox"]') ?? null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Menu item. `hasSubmenu` is read during render and spread its children.
|
|
30
|
+
get hasSubmenu(): boolean {
|
|
31
|
+
return [...(this.children ?? [])].some((child) => child.slot === 'submenu')
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Before those two guards, neither component could be server-rendered at all. Both
|
|
36
|
+
looked correct in every browser, because the client always has a root and always
|
|
37
|
+
has a tree.
|
|
38
|
+
|
|
39
|
+
**Anything that inspects the tree runs in `firstUpdated` or later**, and is
|
|
40
|
+
therefore a client-side enhancement by definition.
|
|
41
|
+
|
|
42
|
+
## Never initialise state in `connectedCallback`
|
|
43
|
+
|
|
44
|
+
SSR does not call it. Derive from properties, or seed in `willUpdate`, which SSR
|
|
45
|
+
does call.
|
|
46
|
+
|
|
47
|
+
## Guard generated ids
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
if (!this.id) {
|
|
51
|
+
this.id = `helix-${crypto.randomUUID()}`
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The server writes the id into the HTML; the client sees it present and skips
|
|
56
|
+
generating a new one, so both agree. Without the guard they disagree and the
|
|
57
|
+
association breaks on hydration.
|
|
58
|
+
|
|
59
|
+
## Semantics on the host, presentation in the shadow root
|
|
60
|
+
|
|
61
|
+
IDREFs do not cross shadow boundaries, so `aria-labelledby` inside a shadow root
|
|
62
|
+
can never resolve to a light-DOM id. Use `ElementInternals`, which writes no
|
|
63
|
+
attribute at all, so the host stays exactly as you wrote it and hydration has
|
|
64
|
+
nothing to disagree about.
|
|
65
|
+
|
|
66
|
+
**Labels are the case that catches people.** `<label for>` cannot reach into a
|
|
67
|
+
shadow root either, which is why a field's label is rendered *inside* the
|
|
68
|
+
control rather than by anything wrapping it, and why a wrapping "form field"
|
|
69
|
+
component cannot own the label without handing it down as a property. It is also
|
|
70
|
+
why `aria-label` on a host has to be forwarded rather than left where it was
|
|
71
|
+
written. See [Composition](./composition).
|
|
72
|
+
|
|
73
|
+
Where an id genuinely cannot reach, **element references can**, and they resolve
|
|
74
|
+
outward: an element in a shadow root may point at one in the light DOM.
|
|
75
|
+
`Listbox` uses that for `aria-activedescendant`, because its options are your
|
|
76
|
+
elements and the element that takes focus is in its shadow root. Feature-test
|
|
77
|
+
them: assigning to a property an engine does not implement is a no-op rather
|
|
78
|
+
than an error.
|
|
79
|
+
|
|
80
|
+
## A child announces itself to its parent
|
|
81
|
+
|
|
82
|
+
A parent cannot poll for a child that has not upgraded yet. The child dispatches
|
|
83
|
+
a bubbling ready event and the parent listens.
|
|
84
|
+
|
|
85
|
+
## Writing a server spec
|
|
86
|
+
|
|
87
|
+
Every component has a `*.ssr.spec.ts`, and there is one rule about them worth
|
|
88
|
+
repeating, because it has produced a false pass twice:
|
|
89
|
+
|
|
90
|
+
**Never assert structure against the raw SSR string.** Declarative shadow DOM
|
|
91
|
+
inlines the component's stylesheet into the very markup being asserted, and that
|
|
92
|
+
stylesheet names every part, host attribute and token the component uses. So
|
|
93
|
+
`toContain('part="media"')` passes on a *selector*, and
|
|
94
|
+
`not.toContain('data-scrolled')` fails on a `:host([data-scrolled])` *rule*,
|
|
95
|
+
both saying nothing about what rendered.
|
|
96
|
+
|
|
97
|
+
Use the helpers: `withoutStyles`, `hasPart`, `partTag`, `hostTag`. And match
|
|
98
|
+
attribute bindings with a pattern, since they render across several lines:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
expect(markup).toMatch(/<a\s+part="button"/) // ✅
|
|
102
|
+
expect(markup).toContain('<a part="button"') // ❌ never matches
|
|
103
|
+
```
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Five radii, two widths, and the two tokens that are a policy rather than a value.
|
|
3
|
+
section: Foundations
|
|
4
|
+
title: Shape and borders
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Shape and borders
|
|
8
|
+
|
|
9
|
+
## Radii
|
|
10
|
+
|
|
11
|
+
[//]: # (tokens:radii)
|
|
12
|
+
|
|
13
|
+
| Token | Value |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| `--helix-radii-none` | `0` |
|
|
16
|
+
| `--helix-radii-sm` | `calc(var(--helix-seed-radii, 0.5rem) * 0.5)` |
|
|
17
|
+
| `--helix-radii-md` | `var(--helix-seed-radii, 0.5rem)` |
|
|
18
|
+
| `--helix-radii-lg` | `calc(var(--helix-seed-radii, 0.5rem) * 1.5)` |
|
|
19
|
+
| `--helix-radii-full` | `9999px` |
|
|
20
|
+
|
|
21
|
+
[//]: # (/tokens)
|
|
22
|
+
|
|
23
|
+
`full` is `9999px` rather than `50%`, which matters for anything that is not
|
|
24
|
+
square: a percentage radius on a wide element gives an ellipse, and a pill is
|
|
25
|
+
what you wanted.
|
|
26
|
+
|
|
27
|
+
Rounding is the fastest single change to how a system feels, and it is one
|
|
28
|
+
token:
|
|
29
|
+
|
|
30
|
+
```css
|
|
31
|
+
:root {
|
|
32
|
+
--helix-radii-md: 2px;
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
That makes every card, dialog, input and button squarer at once. The theme
|
|
37
|
+
engine takes a `radius` intent input for exactly this reason. See [Deriving a
|
|
38
|
+
theme](./theming).
|
|
39
|
+
|
|
40
|
+
**A shape that is round rather than softened does not use these.** An avatar, a
|
|
41
|
+
loader and a radio are circles because of what they are, so they say `50%`
|
|
42
|
+
directly and are unaffected by a theme that squares everything else off.
|
|
43
|
+
|
|
44
|
+
## Border widths
|
|
45
|
+
|
|
46
|
+
[//]: # (tokens:border)
|
|
47
|
+
|
|
48
|
+
| Token | Value |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| `--helix-border-thin` | `1px` |
|
|
51
|
+
| `--helix-border-thick` | `2px` |
|
|
52
|
+
| `--helix-border-surface` | `var(--helix-border-thin)` |
|
|
53
|
+
| `--helix-border-floating` | `0` |
|
|
54
|
+
|
|
55
|
+
[//]: # (/tokens)
|
|
56
|
+
|
|
57
|
+
The first two are values you pick. **The second two are a decision**, and they
|
|
58
|
+
belong to [Surfaces and separation](./surfaces-and-separation): they are what a
|
|
59
|
+
theme sets to zero to go shadow-first, or to a width to go border-first.
|
|
60
|
+
|
|
61
|
+
This distinction is why a component's border width sits behind a per-component
|
|
62
|
+
hook rather than being written inline. Of 68 border declarations reaching for
|
|
63
|
+
`--helix-color-border` before this was fixed, eight put the *color* behind a
|
|
64
|
+
hook and none put the width behind one, so a border could be recoloured and
|
|
65
|
+
never removed, and the floating surfaces wrote `border: none` outright so one
|
|
66
|
+
could never be added either.
|
|
67
|
+
|
|
68
|
+
## Corner radius on a group
|
|
69
|
+
|
|
70
|
+
Adjacent controls in a group flatten the corners between them, so a button group
|
|
71
|
+
reads as one shape rather than three:
|
|
72
|
+
|
|
73
|
+
```css
|
|
74
|
+
[data-button]:not(:first-child) { border-start-start-radius: 0; }
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The base layer does this for the recipes, and the components do it for
|
|
78
|
+
themselves. If you build your own group: it is the inner corners that go flat,
|
|
79
|
+
not the outer ones, and it is `border-start-start-radius` rather than
|
|
80
|
+
`border-top-left-radius` so it follows the writing direction.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Why no control has a size, and what to write instead.
|
|
3
|
+
section: Theming
|
|
4
|
+
title: Size and density
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Size and density
|
|
8
|
+
|
|
9
|
+
**A control has no size. It takes its height from the region it is in.**
|
|
10
|
+
|
|
11
|
+
```html
|
|
12
|
+
<helix-flex data-density="lg">
|
|
13
|
+
<helix-button
|
|
14
|
+
emphasis="solid"
|
|
15
|
+
tone="primary"
|
|
16
|
+
>See what is shipping</helix-button>
|
|
17
|
+
</helix-flex>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
There is no `size` prop on a button, an input, a select or a checkbox. That
|
|
21
|
+
looks like a restriction and is not one.
|
|
22
|
+
|
|
23
|
+
## Size does three jobs
|
|
24
|
+
|
|
25
|
+
**Emphasis.** The `emphasis` axis carries it. A filled primary button says "this one
|
|
26
|
+
matters" far louder than eight more pixels ever will.
|
|
27
|
+
|
|
28
|
+
**Fit.** This is a fact about the region rather than about the control. A
|
|
29
|
+
toolbar is tight, a hero is spacious, a table row is tighter still. The control
|
|
30
|
+
does not know which it is in; the region does.
|
|
31
|
+
|
|
32
|
+
**Optical role.** Real, and only for things that are not controls. An avatar at
|
|
33
|
+
24 in a list and 96 on a profile is a different graphic, not a taller control.
|
|
34
|
+
That is why `size` survives on `Avatar` and `Spinner` and nowhere else: those
|
|
35
|
+
are pictures.
|
|
36
|
+
|
|
37
|
+
## The test that settles it
|
|
38
|
+
|
|
39
|
+
*Can two controls sitting together legitimately differ in height?*
|
|
40
|
+
|
|
41
|
+
Save and Cancel. An icon button beside a text button. An input and its submit.
|
|
42
|
+
The two halves of a split button. In every case the answer is no.
|
|
43
|
+
|
|
44
|
+
So a per-instance size is only ever used to say something about the **region**,
|
|
45
|
+
one element at a time, which is exactly how a row ends up with one member the
|
|
46
|
+
wrong height. Said once on the region, a ragged row is not discouraged. It is
|
|
47
|
+
unwritable.
|
|
48
|
+
|
|
49
|
+
That is also what makes this safe for anything generating code, whether that is
|
|
50
|
+
a person or a model: there is no size to get wrong, and one question to ask per
|
|
51
|
+
region instead of one per element.
|
|
52
|
+
|
|
53
|
+
## How to use it
|
|
54
|
+
|
|
55
|
+
Put `data-density` on any container. `sm`, `md` or `lg`. `md` is the default and
|
|
56
|
+
is never written.
|
|
57
|
+
|
|
58
|
+
```html
|
|
59
|
+
<helix-toolbar>
|
|
60
|
+
<!-- Nothing here needs a size. The toolbar is already dense. -->
|
|
61
|
+
<helix-button>Run</helix-button>
|
|
62
|
+
<helix-select label="Target"><!-- … --></helix-select>
|
|
63
|
+
</helix-toolbar>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Containers that are dense by nature declare it themselves.** The app header,
|
|
67
|
+
the toolbar and the announcement all do, so a control put in one is already the
|
|
68
|
+
right height without anybody deciding.
|
|
69
|
+
|
|
70
|
+
It works through custom properties (`--helix-size-control`,
|
|
71
|
+
`--helix-size-checkbox`, `--helix-size-switch`, `--helix-size-tag`), which is
|
|
72
|
+
why it costs no component any API and crosses every shadow boundary on its own.
|
|
73
|
+
|
|
74
|
+
The `-sm` / `-md` / `-lg` tokens are the scale and never move. The unsuffixed
|
|
75
|
+
ones are the current step, and `data-density` is what moves them.
|
|
76
|
+
|
|
77
|
+
## What this is not
|
|
78
|
+
|
|
79
|
+
**`data-link-size` stays.** A text link has no control height: its size is
|
|
80
|
+
typography, and it follows the text around it.
|
|
81
|
+
|
|
82
|
+
**A control may still declare its own scale.** `--helix-size-switch` is one step
|
|
83
|
+
above `--helix-size-checkbox` at every density, because a switch is a track with
|
|
84
|
+
a thumb inside it and a checkbox is a box with a mark in it. That is the
|
|
85
|
+
component choosing where it sits on the scale, once, for everyone. It is not the
|
|
86
|
+
same thing as a consumer setting a height per instance.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: One spacing scale, and why a control's height is not on it.
|
|
3
|
+
section: Foundations
|
|
4
|
+
title: Space and size
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Space and size
|
|
8
|
+
|
|
9
|
+
Two scales that look similar and answer different questions. Space is the gaps
|
|
10
|
+
between things. Size is how big a control is. Mixing them is how a layout ends
|
|
11
|
+
up with a button that is one step of padding taller than the input beside it.
|
|
12
|
+
|
|
13
|
+
## Space
|
|
14
|
+
|
|
15
|
+
[//]: # (tokens:space)
|
|
16
|
+
|
|
17
|
+
| Token | Value |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| `--helix-space-none` | `0rem` |
|
|
20
|
+
| `--helix-space-2xs` | `calc(0.25rem * var(--helix-seed-density, 1))` |
|
|
21
|
+
| `--helix-space-xs` | `calc(0.5rem * var(--helix-seed-density, 1))` |
|
|
22
|
+
| `--helix-space-sm` | `calc(0.75rem * var(--helix-seed-density, 1))` |
|
|
23
|
+
| `--helix-space-md` | `calc(1rem * var(--helix-seed-density, 1))` |
|
|
24
|
+
| `--helix-space-lg` | `calc(1.5rem * var(--helix-seed-density, 1))` |
|
|
25
|
+
| `--helix-space-xl` | `calc(2rem * var(--helix-seed-density, 1))` |
|
|
26
|
+
| `--helix-space-2xl` | `calc(3rem * var(--helix-seed-density, 1))` |
|
|
27
|
+
| `--helix-space-3xl` | `calc(4rem * var(--helix-seed-density, 1))` |
|
|
28
|
+
| `--helix-space-control` | `var(--helix-space-md)` |
|
|
29
|
+
|
|
30
|
+
[//]: # (/tokens)
|
|
31
|
+
|
|
32
|
+
Nine steps, t-shirt named rather than numbered. A numbered scale invites
|
|
33
|
+
arithmetic (`space-4` is twice `space-2`) which stops being true the moment the
|
|
34
|
+
scale is tuned, and invites a `space-4.5` the first time something does not fit.
|
|
35
|
+
|
|
36
|
+
`--helix-space-control` is the tenth and is different in kind: it is the inset a
|
|
37
|
+
control uses, and it follows the region's density rather than sitting on the
|
|
38
|
+
scale. See [Size and density](./size-and-density).
|
|
39
|
+
|
|
40
|
+
Use them for gap, padding and margin:
|
|
41
|
+
|
|
42
|
+
```css
|
|
43
|
+
.toolbar {
|
|
44
|
+
display: flex;
|
|
45
|
+
gap: var(--helix-space-sm);
|
|
46
|
+
padding: var(--helix-space-md);
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Size
|
|
51
|
+
|
|
52
|
+
Thirty-four tokens, and almost all of them name a specific thing:
|
|
53
|
+
`--helix-size-control`, `--helix-size-checkbox`, `--helix-size-switch`,
|
|
54
|
+
`--helix-size-avatar`, `--helix-size-icon`, `--helix-size-tag`,
|
|
55
|
+
`--helix-size-header`, `--helix-size-progress`, `--helix-size-meter`.
|
|
56
|
+
|
|
57
|
+
Most come in a `-sm`, `-md` and `-lg` set beside the unsuffixed one. **The
|
|
58
|
+
unsuffixed token is the live one** and the suffixed ones are the values the
|
|
59
|
+
density axis picks between. The loader is the exception: it has `-sm`, `-md`
|
|
60
|
+
and `-lg` and no unsuffixed token above them, because nothing reads a loader's
|
|
61
|
+
size off the density. So a component reads `--helix-size-control` and the
|
|
62
|
+
region's `[data-density]` decides which of `-sm`, `-md` or `-lg` that resolves
|
|
63
|
+
to.
|
|
64
|
+
|
|
65
|
+
That is why a component has no `size` property: the answer is the region's, and
|
|
66
|
+
saying it once there means a ragged row is not merely discouraged, it is
|
|
67
|
+
unwritable.
|
|
68
|
+
|
|
69
|
+
## The focus ring is three tokens
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
--helix-size-focus-ring the ring's own thickness
|
|
73
|
+
--helix-size-focus-offset the gap between the control and the ring
|
|
74
|
+
--helix-size-focus-reach ring plus offset, for reserving room
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The third exists because a ring drawn outside a control needs room, and every
|
|
78
|
+
component that draws one otherwise recomputes the sum. Reserve with `reach`,
|
|
79
|
+
draw with the other two.
|
|
80
|
+
|
|
81
|
+
## What not to do
|
|
82
|
+
|
|
83
|
+
**Do not use a size token for a gap.** `--helix-size-control` is a height, and a
|
|
84
|
+
layout that uses it as a gap changes meaning the day a density changes under it.
|
|
85
|
+
|
|
86
|
+
**Do not use a raw value because the scale has no step for it.** If a gap is
|
|
87
|
+
genuinely between `sm` and `md`, say so rather than writing `11px`: it is
|
|
88
|
+
usually a sign the two things being separated belong at different levels of the
|
|
89
|
+
layout.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Why a declared property is a seed, and how to take a decision when you want it.
|
|
3
|
+
section: Essentials
|
|
4
|
+
title: State and control
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# State and control
|
|
8
|
+
|
|
9
|
+
**A declared property is a seed and a correction.** Write `value`, `open`,
|
|
10
|
+
`checked` or `active` on a Helix element and it starts there and goes on
|
|
11
|
+
managing itself, exactly as `<input value="Ada">` does.
|
|
12
|
+
|
|
13
|
+
```html
|
|
14
|
+
<helix-input
|
|
15
|
+
label="Name"
|
|
16
|
+
value="Ada"
|
|
17
|
+
></helix-input>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
That works. Type in it and the value moves. Assign to it later and the value
|
|
21
|
+
moves to what you assigned. There is no mode to be in and nothing to wire before
|
|
22
|
+
the control responds.
|
|
23
|
+
|
|
24
|
+
*This page said the opposite until 26 Aug 2026, and the sample above was printed
|
|
25
|
+
as plain text with an instruction not to paste it. Writing a property was a
|
|
26
|
+
claim of ownership, so the element announced its changes and applied none of
|
|
27
|
+
them until it was told, and that markup produced a field that ignored every
|
|
28
|
+
keystroke with nothing logged and nothing visibly wrong.*
|
|
29
|
+
|
|
30
|
+
## Taking a decision
|
|
31
|
+
|
|
32
|
+
Control is an action rather than a mode. Every change announces before it acts,
|
|
33
|
+
and the announcement is cancelable:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
input.addEventListener('helix-before-change', (event) => {
|
|
37
|
+
if (locked) {
|
|
38
|
+
event.preventDefault()
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`preventDefault()` refuses that one change and nothing moves. To own the state
|
|
44
|
+
outright, refuse and assign your own:
|
|
45
|
+
|
|
46
|
+
```tsx
|
|
47
|
+
<helix-input
|
|
48
|
+
label="Name"
|
|
49
|
+
onhelix-before-change={(event) => {
|
|
50
|
+
event.preventDefault()
|
|
51
|
+
setName(sanitize(event.detail.value))
|
|
52
|
+
}}
|
|
53
|
+
value={name}
|
|
54
|
+
/>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
This is the same shape the platform uses everywhere else: `preventDefault()` on
|
|
58
|
+
a form submit, on a link click, on `beforeinput`. Nothing is declared in advance
|
|
59
|
+
and nothing is inferred from which properties you happened to set.
|
|
60
|
+
|
|
61
|
+
The imperative form reports the same thing:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
const applied = item.requestOpen(true)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`false` means something refused it.
|
|
68
|
+
|
|
69
|
+
## Mirroring the state instead
|
|
70
|
+
|
|
71
|
+
Most consumers want neither of the above. Bind the notification and let the
|
|
72
|
+
element apply:
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
<helix-input label="Name" onChange={(event) => setName(event.currentTarget.value)} />
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
```html
|
|
79
|
+
<helix-input
|
|
80
|
+
label="Name"
|
|
81
|
+
onchange="save(this.value)"
|
|
82
|
+
></helix-input>
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The native event carries no detail because it does not need one: the element has
|
|
86
|
+
already applied the change by the time it fires, so the value is on the target
|
|
87
|
+
where every handler ever written expects to find it.
|
|
88
|
+
|
|
89
|
+
## `default-` still means something
|
|
90
|
+
|
|
91
|
+
```html
|
|
92
|
+
<helix-input
|
|
93
|
+
default-value="Ada"
|
|
94
|
+
label="Name"
|
|
95
|
+
></helix-input>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`default-value` is what a reset returns to. `value` is where the control is now.
|
|
99
|
+
The pair is the platform's, where a native input reflects its `value` *content
|
|
100
|
+
attribute* to `defaultValue` and carries a dirty flag that stops the attribute
|
|
101
|
+
affecting the value once a reader has typed.
|
|
102
|
+
|
|
103
|
+
## State is a custom state, not an attribute
|
|
104
|
+
|
|
105
|
+
```css
|
|
106
|
+
helix-accordion-item:state(open) { … }
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`open` is not reflected, and neither is `value`. A payload written back as an
|
|
110
|
+
attribute leaks whatever the last reader typed into the markup, so a serialized
|
|
111
|
+
host comes back holding their text rather than the author's seed, with no way to
|
|
112
|
+
tell the two apart. `helix-input` takes `type="password"`, which a reflected
|
|
113
|
+
`value` would carry straight past every session-replay masker that knows to mask
|
|
114
|
+
`.value` and `input[value]`.
|
|
115
|
+
|
|
116
|
+
So the resolved boolean is published as a custom state and the plain attribute
|
|
117
|
+
stays what you wrote. `:state(open)`, `:state(checked)`, `:state(invalid)` and
|
|
118
|
+
`:state(read)` are the four, and they work from your own stylesheet exactly as
|
|
119
|
+
an attribute selector did:
|
|
120
|
+
|
|
121
|
+
```css
|
|
122
|
+
helix-checkbox:state(checked)::part(control) { … }
|
|
123
|
+
helix-input:state(invalid)::part(field) { … }
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**These were `data-open` and `data-checked` until 27 Aug 2026.** An attribute is
|
|
127
|
+
in the DOM, and the element writes it on its first update, so a framework that
|
|
128
|
+
server-renders your markup and then hydrates it finds attributes its own render
|
|
129
|
+
never produced. React reports that as a hydration mismatch and does not patch it
|
|
130
|
+
up. A custom state is not in the DOM: it reaches your stylesheet and leaves the
|
|
131
|
+
markup alone, so the two renders agree by construction. If you were selecting on
|
|
132
|
+
the old names, the change is mechanical.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: The four ways a surface is told apart, and the ground a component is not.
|
|
3
|
+
section: Foundations
|
|
4
|
+
title: Surfaces and separation
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Surfaces and separation
|
|
8
|
+
|
|
9
|
+
**A surface is told apart from what is behind it by exactly one of four things:
|
|
10
|
+
a fill, an edge, a lift, or space.** A design language leads with one of them.
|
|
11
|
+
Helix leads with the edge on the page and with the lift above it: a card takes a
|
|
12
|
+
hairline and no shadow, a menu takes a shadow and no hairline. The ladder below
|
|
13
|
+
decides what a surface sits on.
|
|
14
|
+
|
|
15
|
+
## The four mechanisms
|
|
16
|
+
|
|
17
|
+
| | |
|
|
18
|
+
|---|---|
|
|
19
|
+
| **Fill** | A different ground color to what is behind it |
|
|
20
|
+
| **Edge** | A border |
|
|
21
|
+
| **Lift** | A shadow |
|
|
22
|
+
| **Space** | Nothing at all, and the gap does the work |
|
|
23
|
+
|
|
24
|
+
Most systems use two or three without deciding, which is how you end up with a
|
|
25
|
+
card that has a border, a shadow *and* a tint, each added by someone solving the
|
|
26
|
+
same problem again.
|
|
27
|
+
|
|
28
|
+
Helix expresses the choice in two pairs of policy tokens:
|
|
29
|
+
|
|
30
|
+
[//]: # (tokens:separation-policy)
|
|
31
|
+
|
|
32
|
+
| Token | Value | For |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| `--helix-border-surface` | `var(--helix-border-thin)` | The edge a surface in the flow takes. Zero to go shadow-first. |
|
|
35
|
+
| `--helix-shadow-surface` | `var(--helix-shadow-none)` | The lift a surface in the flow takes. None to go border-first. |
|
|
36
|
+
| `--helix-border-floating` | `0` | The edge a surface over content takes. |
|
|
37
|
+
| `--helix-shadow-floating` | `var(--helix-shadow-lg)` | The lift a surface over content takes. |
|
|
38
|
+
|
|
39
|
+
[//]: # (/tokens)
|
|
40
|
+
|
|
41
|
+
Set the border pair to zero and the shadow pair to something, and the whole
|
|
42
|
+
library goes shadow-first. Reverse them and it goes border-first. Every surface
|
|
43
|
+
in the library is themeable in both directions, and checked, because a
|
|
44
|
+
half-converted one reads as done: hook the shadow and hardcode `border: none`
|
|
45
|
+
and it themes perfectly in one direction and cannot be moved in the other.
|
|
46
|
+
|
|
47
|
+
## The ladder
|
|
48
|
+
|
|
49
|
+
A surface's ground says where it is, not what shade it is:
|
|
50
|
+
|
|
51
|
+
[//]: # (tokens:surface-ladder)
|
|
52
|
+
|
|
53
|
+
| Token | Sample | For |
|
|
54
|
+
| --- | --- | --- |
|
|
55
|
+
| `--helix-color-background` | <span data-token-swatch style={{ background: 'var(--helix-color-background)' }} /> | The page. Told apart by nothing, because it is the ground. |
|
|
56
|
+
| `--helix-color-surface` | <span data-token-swatch style={{ background: 'var(--helix-color-surface)' }} /> | A plane on the page: a card, or a panel you own. Told apart by a fill, an edge or a lift, whichever the policy leads with. |
|
|
57
|
+
| `--helix-color-surface-raised` | <span data-token-swatch style={{ background: 'var(--helix-color-surface-raised)' }} /> | Lifted while still lying on the page: a selected tab, a hovered row. Told apart by its fill, which goes down the ramp rather than up. |
|
|
58
|
+
| `--helix-color-surface-overlay` | <span data-token-swatch style={{ background: 'var(--helix-color-surface-overlay)' }} /> | Over the page rather than on it: a menu, a popover, a dialog. Told apart by its shadow and by what it covers. |
|
|
59
|
+
| `--helix-color-surface-sunken` | <span data-token-swatch style={{ background: 'var(--helix-color-surface-sunken)' }} /> | A well a value goes into: an input, a track, a groove. Told apart by its fill, inward. |
|
|
60
|
+
|
|
61
|
+
[//]: # (/tokens)
|
|
62
|
+
|
|
63
|
+
**`raised` goes down the ramp, not up**, which is the counterintuitive one. A
|
|
64
|
+
selected tab's pill, a hovered row and an option under the pointer all have the
|
|
65
|
+
page on every side, so they are told apart by their fill, and on a white page a
|
|
66
|
+
fill has to be a step *darker* to be seen. `overlay` is the opposite: it is told
|
|
67
|
+
apart by its shadow and by covering something, so on white it is white.
|
|
68
|
+
|
|
69
|
+
## The common mistake
|
|
70
|
+
|
|
71
|
+
**`--helix-color-background` is the page, and your component is not the page.**
|
|
72
|
+
|
|
73
|
+
```css
|
|
74
|
+
/* Wrong. Claims the component is sitting on the page. */
|
|
75
|
+
.marker { background: var(--helix-color-background); }
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Every use of the page's color inside a component is a claim about what that
|
|
79
|
+
component is sitting on, and the claim is right exactly as often as the
|
|
80
|
+
component is put on the page and no other ground. `Timeline`'s marker made it,
|
|
81
|
+
painting itself the page color to knock the rail out behind it, and came out
|
|
82
|
+
darker than any surface that was not the page: on a card it showed as a black
|
|
83
|
+
disc. The sparkline's ring terminus made the same mistake and looked identical.
|
|
84
|
+
|
|
85
|
+
The trap is that both of those *worked*, for months, because the page is what
|
|
86
|
+
anybody building a component puts it on. It fails on a card, a drawer, a toast,
|
|
87
|
+
a panel or a photograph, and none of those is where a component gets built.
|
|
88
|
+
|
|
89
|
+
**What to do instead:** put the color behind a hook that defaults to the page,
|
|
90
|
+
so a consumer whose component is not on the page can correct it. That is what
|
|
91
|
+
the library requires of itself, and the same reasoning applies to anything you
|
|
92
|
+
build on top.
|
|
93
|
+
|
|
94
|
+
## Contrast
|
|
95
|
+
|
|
96
|
+
The theme engine measures an ink against the ground it is *really* painted on,
|
|
97
|
+
which is why the ladder is a model rather than a naming convention: the engine
|
|
98
|
+
has to know that `foreground` on `surface-overlay` is a real pair and
|
|
99
|
+
`foreground-on-accent` on `surface` is not. See [Color](./color).
|