@fusion.dev/helix-templates 0.34.2 → 0.35.1
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/README.md +9 -2
- package/dist/index.js +4 -4
- package/package.json +1 -1
- package/templates/react-nextjs/app/app.css +363 -0
- package/templates/react-nextjs/app/data.ts +81 -0
- package/templates/react-nextjs/app/docs.ts +29 -0
- package/templates/react-nextjs/app/join/page.tsx +12 -0
- package/templates/react-nextjs/app/landing.tsx +191 -0
- package/templates/react-nextjs/app/layout.tsx +14 -2
- package/templates/react-nextjs/app/overview/page.tsx +23 -0
- package/templates/react-nextjs/app/overview.tsx +91 -0
- package/templates/react-nextjs/app/page.tsx +8 -13
- package/templates/react-nextjs/app/signup-form.tsx +171 -0
- package/templates/react-nextjs/app/site-footer.tsx +38 -0
- package/templates/react-nextjs/app/site-header.tsx +95 -0
- package/templates/react-vite/src/App.tsx +29 -17
- package/templates/react-vite/src/app.css +363 -0
- package/templates/react-vite/src/data.ts +81 -0
- package/templates/react-vite/src/docs.ts +29 -0
- package/templates/react-vite/src/landing.tsx +191 -0
- package/templates/react-vite/src/main.tsx +2 -0
- package/templates/react-vite/src/overview.tsx +91 -0
- package/templates/react-vite/src/routes.tsx +39 -0
- package/templates/react-vite/src/signup-form.tsx +162 -0
- package/templates/react-vite/src/site-footer.tsx +38 -0
- package/templates/react-vite/src/site-header.tsx +85 -0
- package/templates.json +4 -4
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { roster } from './data.js'
|
|
2
|
+
import { DOCS } from './docs.js'
|
|
3
|
+
import { Link } from './routes.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* **A locale named rather than left to the machine.** `toLocaleString()` with no
|
|
7
|
+
* argument reads the environment's locale, which is the server's in one template
|
|
8
|
+
* and the reader's in both, so the same number is grouped two ways and the
|
|
9
|
+
* markup React hydrates does not match the markup it rendered.
|
|
10
|
+
*/
|
|
11
|
+
const figure = new Intl.NumberFormat('en-US')
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The first screen, and the only page here that is about the starter rather
|
|
15
|
+
* than built out of it.
|
|
16
|
+
*
|
|
17
|
+
* Delete it once you know your way around: nothing else imports it, and the
|
|
18
|
+
* route it answers becomes yours.
|
|
19
|
+
*
|
|
20
|
+
* **The heading says what this is, and the project's name stays in the bar.** A
|
|
21
|
+
* scaffold called `my-app` opening on an `<h1>my-app</h1>` tells its reader
|
|
22
|
+
* nothing they did not type thirty seconds ago, and spends the loudest line on
|
|
23
|
+
* the page doing it.
|
|
24
|
+
*
|
|
25
|
+
* **Anchors, not a `Button` with an `href`.** A button that navigates is a link
|
|
26
|
+
* wearing a button's clothes, and the two are different things to a keyboard and
|
|
27
|
+
* to a screen reader. The base stylesheet styles an anchor that asks with
|
|
28
|
+
* `data-button`, so a link can look like one without being one.
|
|
29
|
+
*/
|
|
30
|
+
export function Landing() {
|
|
31
|
+
return (
|
|
32
|
+
<div className="stack">
|
|
33
|
+
<header className="first-screen">
|
|
34
|
+
<div className="hero">
|
|
35
|
+
<helix-text emphasis="muted" variant="label">
|
|
36
|
+
Helix starter
|
|
37
|
+
</helix-text>
|
|
38
|
+
<helix-text as="h1">Every tag on this page is Helix</helix-text>
|
|
39
|
+
<helix-text as="p" className="lede" emphasis="muted" variant="body">
|
|
40
|
+
Nothing here is a wrapper: the tags are the library's own elements, and every color,
|
|
41
|
+
size and space inside them comes from one set of tokens. Three routes are laid down, and
|
|
42
|
+
all of them are yours.
|
|
43
|
+
</helix-text>
|
|
44
|
+
<div className="actions">
|
|
45
|
+
<Link data-button="solid" data-button-tone="primary" href="/overview">
|
|
46
|
+
See the data view
|
|
47
|
+
</Link>
|
|
48
|
+
<Link data-button href="/join">
|
|
49
|
+
Open the form
|
|
50
|
+
</Link>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
{/* **What the first screen has to look at, and the honest thing to look
|
|
55
|
+
at is the library working.** The figures are the roster the data view
|
|
56
|
+
draws, read from the same module, so the panel is the app rather than
|
|
57
|
+
a decoration of it. `helix-stat` sets the number on the display face
|
|
58
|
+
at a size the type scale does not reach, which is the one place on a
|
|
59
|
+
page where the number is the design. */}
|
|
60
|
+
<helix-card className="specimen" separation="outlined">
|
|
61
|
+
<helix-text as="h2" slot="header" variant="title">
|
|
62
|
+
The roster, in three figures
|
|
63
|
+
</helix-text>
|
|
64
|
+
<helix-stat-group divided>
|
|
65
|
+
<helix-stat>
|
|
66
|
+
{roster.people}
|
|
67
|
+
<span slot="label">People</span>
|
|
68
|
+
</helix-stat>
|
|
69
|
+
<helix-stat>
|
|
70
|
+
{roster.teams}
|
|
71
|
+
<span slot="label">Teams</span>
|
|
72
|
+
</helix-stat>
|
|
73
|
+
<helix-stat>
|
|
74
|
+
{figure.format(roster.reviews)}
|
|
75
|
+
<span slot="label">Reviews</span>
|
|
76
|
+
</helix-stat>
|
|
77
|
+
</helix-stat-group>
|
|
78
|
+
<Link data-link href="/overview" slot="footer">
|
|
79
|
+
Open the data view
|
|
80
|
+
</Link>
|
|
81
|
+
</helix-card>
|
|
82
|
+
</header>
|
|
83
|
+
|
|
84
|
+
<section className="section">
|
|
85
|
+
<helix-text as="h2" variant="subheading">
|
|
86
|
+
What you have
|
|
87
|
+
</helix-text>
|
|
88
|
+
{/* Three paragraphs about one installed thing, so they take a rule each
|
|
89
|
+
rather than a box each. `app.css` says why at more length. */}
|
|
90
|
+
<div className="facts">
|
|
91
|
+
<article className="fact">
|
|
92
|
+
<helix-text as="h3" variant="title">
|
|
93
|
+
Elements, not wrappers
|
|
94
|
+
</helix-text>
|
|
95
|
+
<helix-text as="p" emphasis="muted" variant="body">
|
|
96
|
+
They are custom elements, so you write the tags and there is no per-component import
|
|
97
|
+
to keep in step. One line in <helix-code>src/main.tsx</helix-code> registers all of
|
|
98
|
+
them, and a subpath like <helix-code>@fusion.dev/helix/tabs</helix-code> registers
|
|
99
|
+
one.
|
|
100
|
+
</helix-text>
|
|
101
|
+
<a data-link href={DOCS.elements}>
|
|
102
|
+
Using the elements
|
|
103
|
+
</a>
|
|
104
|
+
</article>
|
|
105
|
+
|
|
106
|
+
<article className="fact">
|
|
107
|
+
<helix-text as="h3" variant="title">
|
|
108
|
+
One set of tokens
|
|
109
|
+
</helix-text>
|
|
110
|
+
<helix-text as="p" emphasis="muted" variant="body">
|
|
111
|
+
Every color is written once as a light and dark pair, so the control in the bar moves
|
|
112
|
+
the whole page by setting a single attribute on the root. Type, spacing, radii and
|
|
113
|
+
control sizes come from the same place.
|
|
114
|
+
</helix-text>
|
|
115
|
+
<a data-link href={DOCS.tokens}>
|
|
116
|
+
Tokens
|
|
117
|
+
</a>
|
|
118
|
+
</article>
|
|
119
|
+
|
|
120
|
+
<article className="fact">
|
|
121
|
+
<helix-text as="h3" variant="title">
|
|
122
|
+
A short stylesheet
|
|
123
|
+
</helix-text>
|
|
124
|
+
<helix-text as="p" emphasis="muted" variant="body">
|
|
125
|
+
<helix-code>src/app.css</helix-code> holds this page's measure, the rhythm between its
|
|
126
|
+
blocks, and nothing a component already draws. Where a component leaves a decision
|
|
127
|
+
open it names a part: the data view reaches one to paint the row you tick.
|
|
128
|
+
</helix-text>
|
|
129
|
+
<a data-link href={DOCS.parts}>
|
|
130
|
+
Overriding a component
|
|
131
|
+
</a>
|
|
132
|
+
</article>
|
|
133
|
+
</div>
|
|
134
|
+
</section>
|
|
135
|
+
|
|
136
|
+
<section className="section">
|
|
137
|
+
<helix-text as="h2" variant="subheading">
|
|
138
|
+
Where to go next
|
|
139
|
+
</helix-text>
|
|
140
|
+
{/* **The whole card is the link, which is what `data-card` is for.** A
|
|
141
|
+
card with an anchor in its footer gives a reader a target the size of
|
|
142
|
+
three words inside a box the size of a paragraph, and the box is what
|
|
143
|
+
they aim at. The recipe is in the base layer: it draws the ring, moves
|
|
144
|
+
the card's border to the brand on hover, and leaves focus alone. So
|
|
145
|
+
nothing inside may be interactive, a link inside a link being neither
|
|
146
|
+
valid nor reachable. */}
|
|
147
|
+
<helix-grid columns={2} gap="md">
|
|
148
|
+
<Link data-card href="/overview">
|
|
149
|
+
<helix-card separation="outlined">
|
|
150
|
+
{/* The route beside the name rather than under the text: slotted
|
|
151
|
+
into the header the two sit on one baseline, and a row of
|
|
152
|
+
cards stretched to a common height has nothing left floating
|
|
153
|
+
in the middle of it. */}
|
|
154
|
+
<helix-flex align="baseline" gap="sm" justify="between" slot="header">
|
|
155
|
+
<helix-text as="h3" variant="title">
|
|
156
|
+
The data view
|
|
157
|
+
</helix-text>
|
|
158
|
+
<helix-text emphasis="subtle" variant="code">
|
|
159
|
+
/overview
|
|
160
|
+
</helix-text>
|
|
161
|
+
</helix-flex>
|
|
162
|
+
<helix-text as="p" emphasis="muted" variant="body">
|
|
163
|
+
Twelve people from a local module, sorted, paged and selected with nothing wired up.
|
|
164
|
+
The columns are markup you can read and the rows are one property, which is what
|
|
165
|
+
keeps the same component working at ten thousand.
|
|
166
|
+
</helix-text>
|
|
167
|
+
</helix-card>
|
|
168
|
+
</Link>
|
|
169
|
+
|
|
170
|
+
<Link data-card href="/join">
|
|
171
|
+
<helix-card separation="outlined">
|
|
172
|
+
<helix-flex align="baseline" gap="sm" justify="between" slot="header">
|
|
173
|
+
<helix-text as="h3" variant="title">
|
|
174
|
+
The form
|
|
175
|
+
</helix-text>
|
|
176
|
+
<helix-text emphasis="subtle" variant="code">
|
|
177
|
+
/join
|
|
178
|
+
</helix-text>
|
|
179
|
+
</helix-flex>
|
|
180
|
+
<helix-text as="p" emphasis="muted" variant="body">
|
|
181
|
+
Five controls a <helix-code><form></helix-code> reads with{' '}
|
|
182
|
+
<helix-code>FormData</helix-code>, because every one of them is a real form
|
|
183
|
+
participant. The email field's complaint is the browser's own.
|
|
184
|
+
</helix-text>
|
|
185
|
+
</helix-card>
|
|
186
|
+
</Link>
|
|
187
|
+
</helix-grid>
|
|
188
|
+
</section>
|
|
189
|
+
</div>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
@@ -10,6 +10,8 @@ import '@fusion.dev/helix-styles/helix.css'
|
|
|
10
10
|
are finding your feet; `@fusion.dev/helix/button` and its siblings are subpath
|
|
11
11
|
exports when you would rather pay for only what you use. */
|
|
12
12
|
import '@fusion.dev/helix'
|
|
13
|
+
/* After both, so a rule of yours that names the same thing wins. */
|
|
14
|
+
import './app.css'
|
|
13
15
|
|
|
14
16
|
import { App } from './App.js'
|
|
15
17
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { members } from './data.js'
|
|
2
|
+
import { DOCS } from './docs.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The data view.
|
|
6
|
+
*
|
|
7
|
+
* **The schema is markup and the data is a property.** The columns are elements
|
|
8
|
+
* you can read in the HTML, and `rows` is the whole result handed over at once,
|
|
9
|
+
* because a table of ten thousand rows cannot be written out as elements. The
|
|
10
|
+
* table sorts, pages and selects out of that array on its own: none of it is
|
|
11
|
+
* wired up here, and none of it is a handler you have to write.
|
|
12
|
+
*
|
|
13
|
+
* **Selection is keyed on `row-key`, so it survives a sort and a page.** A row
|
|
14
|
+
* is chosen by its identity rather than by where it happened to be sitting, and
|
|
15
|
+
* `app.css` paints the chosen row through the parts the table names for it.
|
|
16
|
+
*/
|
|
17
|
+
export function Overview() {
|
|
18
|
+
return (
|
|
19
|
+
<div className="stack">
|
|
20
|
+
<header className="hero">
|
|
21
|
+
<helix-text emphasis="muted" variant="label">
|
|
22
|
+
Data view
|
|
23
|
+
</helix-text>
|
|
24
|
+
<helix-text as="h1" variant="heading">
|
|
25
|
+
Roster
|
|
26
|
+
</helix-text>
|
|
27
|
+
<helix-text as="p" className="lede" emphasis="muted" variant="body">
|
|
28
|
+
Twelve people from a local module. Sort a column, turn a page, or tick a row and watch the
|
|
29
|
+
rule in <helix-code>src/app.css</helix-code> paint it.
|
|
30
|
+
</helix-text>
|
|
31
|
+
</header>
|
|
32
|
+
|
|
33
|
+
<section className="section">
|
|
34
|
+
<helix-data-table
|
|
35
|
+
default-sort="name:asc"
|
|
36
|
+
label="Roster"
|
|
37
|
+
page-size={6}
|
|
38
|
+
row-key="id"
|
|
39
|
+
rows={members}
|
|
40
|
+
selectable
|
|
41
|
+
>
|
|
42
|
+
{/* **Every column declares what it may give up, and that is the whole of
|
|
43
|
+
the narrow-window story.** A column left to itself takes
|
|
44
|
+
`minmax(max-content, 1fr)`, which refuses to go below its longest
|
|
45
|
+
cell however little room there is, so the last column is pushed
|
|
46
|
+
outside the box and the reader has to scroll the table sideways to
|
|
47
|
+
read a value. A floor and a share instead: the floor is what the
|
|
48
|
+
words need, the share is how the slack is divided. Measured 16 Sep
|
|
49
|
+
2026: at 390 the four columns and the checkboxes come to 358 in a box
|
|
50
|
+
of 358, so nothing is cut and nothing wraps; the floors alone come to
|
|
51
|
+
344, so at a 376 pixel window they exactly fit and below it the table
|
|
52
|
+
scrolls its own box, which is honest, and it fades whichever edge has
|
|
53
|
+
more behind it. */}
|
|
54
|
+
<helix-table-column field="name" sortable width="minmax(5.5rem, 1.6fr)">
|
|
55
|
+
Name
|
|
56
|
+
</helix-table-column>
|
|
57
|
+
<helix-table-column field="team" sortable width="minmax(4.5rem, 1fr)">
|
|
58
|
+
Team
|
|
59
|
+
</helix-table-column>
|
|
60
|
+
<helix-table-column field="role" width="minmax(4.5rem, 1fr)">
|
|
61
|
+
Role
|
|
62
|
+
</helix-table-column>
|
|
63
|
+
{/* `end` lines the digits up under each other, which is the whole reason
|
|
64
|
+
a number column is a different thing from a text one. `max-content`
|
|
65
|
+
because a column of numerals has one right width and no reason to
|
|
66
|
+
take a share of anything. */}
|
|
67
|
+
<helix-table-column align="end" field="reviews" sortable width="max-content">
|
|
68
|
+
Reviews
|
|
69
|
+
</helix-table-column>
|
|
70
|
+
|
|
71
|
+
{/* The footer is a row across the table, not a corner: anything you slot
|
|
72
|
+
goes at the start and the table's own pagination sits at the end. */}
|
|
73
|
+
<helix-text emphasis="subtle" slot="footer" variant="caption">
|
|
74
|
+
{members.length} people
|
|
75
|
+
</helix-text>
|
|
76
|
+
</helix-data-table>
|
|
77
|
+
|
|
78
|
+
{/* A footnote to the table rather than the next block of the page, so it
|
|
79
|
+
takes the section's gap and not the stack's. */}
|
|
80
|
+
<helix-text as="p" emphasis="muted" variant="caption">
|
|
81
|
+
Filtering, column reordering, resizing, virtualized rows and a server mode that asks you
|
|
82
|
+
for a page are all on{' '}
|
|
83
|
+
<a data-link href={DOCS.table}>
|
|
84
|
+
the component's own page
|
|
85
|
+
</a>
|
|
86
|
+
.
|
|
87
|
+
</helix-text>
|
|
88
|
+
</section>
|
|
89
|
+
</div>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type AnchorHTMLAttributes, useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/* **The whole router, which is a hash and a listener.**
|
|
4
|
+
|
|
5
|
+
A starter should not pick your router for you, and two pages need
|
|
6
|
+
`location.hash` and one event. Swap this file for the router you want: the two
|
|
7
|
+
names it exports are Next.js's, so the rest of the app reads the same in
|
|
8
|
+
either template and nothing else here has to change.
|
|
9
|
+
|
|
10
|
+
A hash rather than the History API, because the History API wants a server
|
|
11
|
+
that answers every path with the same document, and `vite preview` and
|
|
12
|
+
whatever you deploy to each have to be told to. A hash asks nothing of
|
|
13
|
+
either. */
|
|
14
|
+
|
|
15
|
+
/** Where the reader is, as a path, so the overview is `/`. */
|
|
16
|
+
const read = (): string => window.location.hash.slice(1) || '/'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A destination, addressed the way the Next.js template addresses one.
|
|
20
|
+
*
|
|
21
|
+
* Everything but `href` goes straight through to the anchor, which is what
|
|
22
|
+
* `next/link` does and is why a `data-button` or a `slot` reads the same in both
|
|
23
|
+
* templates. The path is written as a path: the hash is this component's
|
|
24
|
+
* business and nobody else's.
|
|
25
|
+
*/
|
|
26
|
+
export function Link({ href, ...rest }: AnchorHTMLAttributes<HTMLAnchorElement>) {
|
|
27
|
+
return <a {...rest} href={`#${href ?? '/'}`} />
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The current path, and a re-render whenever it changes. */
|
|
31
|
+
export function usePathname(): string {
|
|
32
|
+
const [path, setPath] = useState(read)
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
const follow = () => setPath(read())
|
|
35
|
+
window.addEventListener('hashchange', follow)
|
|
36
|
+
return () => window.removeEventListener('hashchange', follow)
|
|
37
|
+
}, [])
|
|
38
|
+
return path
|
|
39
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { type FormEvent, useState } from 'react'
|
|
2
|
+
import { teams } from './data.js'
|
|
3
|
+
import { DOCS } from './docs.js'
|
|
4
|
+
|
|
5
|
+
/** One message in the toast region, and what it says. */
|
|
6
|
+
interface Message {
|
|
7
|
+
readonly id: number
|
|
8
|
+
readonly text: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The form.
|
|
13
|
+
*
|
|
14
|
+
* **Every control here is a form-associated custom element**, so a `<form>`
|
|
15
|
+
* collects them, resets them and validates them the way it does an `<input>`.
|
|
16
|
+
* That is what `ElementInternals` buys, and the submit below proves it: it reads
|
|
17
|
+
* the answers with `new FormData(form)` and nothing gathers them by hand.
|
|
18
|
+
*
|
|
19
|
+
* There is no wrapping `Field` component. A label in one shadow root cannot name
|
|
20
|
+
* a control in another, so every control renders its own `label`, `hint` and
|
|
21
|
+
* `feedback` instead.
|
|
22
|
+
*/
|
|
23
|
+
export function SignupForm() {
|
|
24
|
+
const [handle, setHandle] = useState('')
|
|
25
|
+
const [messages, setMessages] = useState<readonly Message[]>([])
|
|
26
|
+
|
|
27
|
+
const join = (event: FormEvent<HTMLFormElement>) => {
|
|
28
|
+
/* The page answers this, so nothing navigates. Remove the line and the form
|
|
29
|
+
posts to the server like any other, with the same names. */
|
|
30
|
+
event.preventDefault()
|
|
31
|
+
const answers = [...new FormData(event.currentTarget).entries()]
|
|
32
|
+
.map(([field, value]) => `${field}=${value}`)
|
|
33
|
+
.join(' ')
|
|
34
|
+
setMessages((sent) => [...sent, { id: Date.now(), text: answers }])
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className="stack">
|
|
39
|
+
<header className="hero">
|
|
40
|
+
<helix-text emphasis="muted" variant="label">
|
|
41
|
+
Form
|
|
42
|
+
</helix-text>
|
|
43
|
+
<helix-text as="h1" variant="heading">
|
|
44
|
+
Join the roster
|
|
45
|
+
</helix-text>
|
|
46
|
+
<helix-text as="p" className="lede" emphasis="muted" variant="body">
|
|
47
|
+
Five controls, and a <helix-code><form></helix-code> reads every one of them the way
|
|
48
|
+
it reads an <helix-code>input</helix-code>.
|
|
49
|
+
</helix-text>
|
|
50
|
+
</header>
|
|
51
|
+
|
|
52
|
+
{/* The form takes a column of its own and the notes take what is left, so a
|
|
53
|
+
wide window is a page rather than a form with a void beside it. At 390
|
|
54
|
+
the two stack and the notes read as a postscript. */}
|
|
55
|
+
<div className="split">
|
|
56
|
+
<form className="form" onSubmit={join}>
|
|
57
|
+
<helix-input autocomplete="name" label="Name" name="name" required />
|
|
58
|
+
|
|
59
|
+
{/* Validation is the platform's: `type` and `required` are set on the
|
|
60
|
+
real input inside, and its own message is what shows once the reader
|
|
61
|
+
has touched the field. Set `feedback` to say it in your own words
|
|
62
|
+
instead, and it shows from the first paint rather than after a
|
|
63
|
+
mistake. */}
|
|
64
|
+
<helix-input
|
|
65
|
+
hint="Where we send the welcome note."
|
|
66
|
+
label="Email"
|
|
67
|
+
name="email"
|
|
68
|
+
required
|
|
69
|
+
type="email"
|
|
70
|
+
/>
|
|
71
|
+
|
|
72
|
+
{/* **A controlled value, which is the one habit to unlearn.** The handler
|
|
73
|
+
is a prop named for the event, so `onhelix-before-change` hears
|
|
74
|
+
`helix-before-change`. A Helix element is not read-only: writing
|
|
75
|
+
`value` seeds it and the element goes on applying its own changes, so
|
|
76
|
+
taking the decision means refusing the change with `preventDefault()`
|
|
77
|
+
and then setting the value you want. Declining to call `setHandle` is
|
|
78
|
+
not a refusal, and leaves the element holding what you did not
|
|
79
|
+
accept. */}
|
|
80
|
+
<helix-input
|
|
81
|
+
hint="Lowercase and hyphens, whatever you type."
|
|
82
|
+
label="Handle"
|
|
83
|
+
name="handle"
|
|
84
|
+
onhelix-before-change={(event) => {
|
|
85
|
+
event.preventDefault()
|
|
86
|
+
setHandle(event.detail.value.toLowerCase().replaceAll(/[^a-z0-9-]/g, ''))
|
|
87
|
+
}}
|
|
88
|
+
value={handle}
|
|
89
|
+
/>
|
|
90
|
+
|
|
91
|
+
<helix-select label="Team" name="team" placeholder="Pick one" required>
|
|
92
|
+
{teams.map((team) => (
|
|
93
|
+
<helix-option key={team} value={team}>
|
|
94
|
+
{team}
|
|
95
|
+
</helix-option>
|
|
96
|
+
))}
|
|
97
|
+
</helix-select>
|
|
98
|
+
|
|
99
|
+
{/* A switch is a thing happening now rather than an answer a form asks
|
|
100
|
+
for, which is why it says on and off. It still submits, as `on`. */}
|
|
101
|
+
<helix-switch label="Send me the weekly digest" name="digest" />
|
|
102
|
+
|
|
103
|
+
<helix-button emphasis="solid" tone="primary" type="submit">
|
|
104
|
+
Join
|
|
105
|
+
</helix-button>
|
|
106
|
+
</form>
|
|
107
|
+
|
|
108
|
+
<aside className="notes">
|
|
109
|
+
{/* The one fact a reader needs before typing their name into a page
|
|
110
|
+
they have had for five minutes, which is why it is a callout and
|
|
111
|
+
not the fourth line of a paragraph. `tone` is the meaning and the
|
|
112
|
+
component decides what that looks like: a tint mixed from the
|
|
113
|
+
status color and a bar on the inline start, so it survives being
|
|
114
|
+
read in grayscale. */}
|
|
115
|
+
<helix-callout tone="info">
|
|
116
|
+
Nothing is posted anywhere. Submit it and the message shows exactly what a server would
|
|
117
|
+
have been handed.
|
|
118
|
+
</helix-callout>
|
|
119
|
+
<helix-text as="h2" variant="title">
|
|
120
|
+
What this is showing
|
|
121
|
+
</helix-text>
|
|
122
|
+
<helix-text as="p" emphasis="muted" variant="body">
|
|
123
|
+
Every control is a form-associated custom element, so the submit reads them with{' '}
|
|
124
|
+
<helix-code>new FormData(form)</helix-code> and nothing gathers the answers by hand.
|
|
125
|
+
Reset the form and they all go back, the way an <helix-code>input</helix-code> does.
|
|
126
|
+
</helix-text>
|
|
127
|
+
<helix-text as="p" emphasis="muted" variant="body">
|
|
128
|
+
Leave the email wrong and the complaint is the browser's own, in the reader's language.
|
|
129
|
+
Handle is the controlled one: it refuses the change and writes back what it will accept.
|
|
130
|
+
</helix-text>
|
|
131
|
+
<a data-link href={DOCS.forms}>
|
|
132
|
+
Forms
|
|
133
|
+
</a>
|
|
134
|
+
</aside>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
{/* **The region is rendered whether or not there is anything in it.**
|
|
138
|
+
Assistive technology subscribes to a live region, and one that arrives
|
|
139
|
+
already holding its message is frequently never announced. Nothing
|
|
140
|
+
creates or destroys a message here either: the items are state, and a
|
|
141
|
+
dismissed one asks to go through `helix-dismiss`. */}
|
|
142
|
+
<helix-toast>
|
|
143
|
+
{messages.map((message) => (
|
|
144
|
+
<helix-toast-item
|
|
145
|
+
dismissible
|
|
146
|
+
duration={8000}
|
|
147
|
+
key={message.id}
|
|
148
|
+
onhelix-dismiss={() => {
|
|
149
|
+
setMessages((sent) => sent.filter((one) => one.id !== message.id))
|
|
150
|
+
}}
|
|
151
|
+
tone="success"
|
|
152
|
+
>
|
|
153
|
+
<helix-flex direction="column" gap="2xs">
|
|
154
|
+
<helix-text variant="body-strong">Read by the form</helix-text>
|
|
155
|
+
<helix-text variant="code">{message.text}</helix-text>
|
|
156
|
+
</helix-flex>
|
|
157
|
+
</helix-toast-item>
|
|
158
|
+
))}
|
|
159
|
+
</helix-toast>
|
|
160
|
+
</div>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { DOCS } from './docs.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The bar's counterpart, on every route.
|
|
5
|
+
*
|
|
6
|
+
* **It is here so that a page ends rather than stops.** The overview is a
|
|
7
|
+
* heading, a table and a caption, which on a tall window left the bottom third
|
|
8
|
+
* of the screen empty and read as a layout that had run out. A footer is the
|
|
9
|
+
* ordinary answer: it puts a floor under the content, and it is where the links
|
|
10
|
+
* a reader wants once rather than constantly belong, which is why the landing
|
|
11
|
+
* page no longer carries them itself.
|
|
12
|
+
*
|
|
13
|
+
* `data-link="subtle"` reads as body text until it is hovered, which is what a
|
|
14
|
+
* row of links under a rule should do: they are a destination, not a sentence
|
|
15
|
+
* asking to be read.
|
|
16
|
+
*/
|
|
17
|
+
export function SiteFooter() {
|
|
18
|
+
return (
|
|
19
|
+
<footer className="site-footer">
|
|
20
|
+
<div className="footer-row">
|
|
21
|
+
<helix-text as="p" emphasis="subtle" variant="caption">
|
|
22
|
+
Every component in here, with its API, its parts and its examples, has a page.
|
|
23
|
+
</helix-text>
|
|
24
|
+
<div className="footer-links">
|
|
25
|
+
<a data-link="subtle" href={DOCS.gallery}>
|
|
26
|
+
Components
|
|
27
|
+
</a>
|
|
28
|
+
<a data-link="subtle" href={DOCS.elements}>
|
|
29
|
+
Documentation
|
|
30
|
+
</a>
|
|
31
|
+
<a data-link="subtle" href={DOCS.react}>
|
|
32
|
+
React
|
|
33
|
+
</a>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</footer>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Link, usePathname } from './routes.js'
|
|
3
|
+
|
|
4
|
+
/** What the page can follow, in the order the control cycles them. */
|
|
5
|
+
const MODES = ['system', 'light', 'dark'] as const
|
|
6
|
+
|
|
7
|
+
type Mode = (typeof MODES)[number]
|
|
8
|
+
|
|
9
|
+
/** The glyph each mode is announced and drawn with. */
|
|
10
|
+
const MARKS: Record<Mode, string> = { dark: 'moon', light: 'sun', system: 'monitor' }
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The bar across the top: the wordmark, the destinations, and the color mode.
|
|
14
|
+
*/
|
|
15
|
+
export function SiteHeader() {
|
|
16
|
+
const path = usePathname()
|
|
17
|
+
/* `system` is the resting state and what the markup is served in, so the
|
|
18
|
+
control draws the same glyph on the server and on the first client paint. */
|
|
19
|
+
const [mode, setMode] = useState<Mode>('system')
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One attribute, two modes, and no second stylesheet.
|
|
23
|
+
*
|
|
24
|
+
* Every Helix token is written once as a `light-dark()` pair, so which half
|
|
25
|
+
* resolves is decided by `color-scheme` and nothing else. `data-color-mode`
|
|
26
|
+
* sets it, and because `color-scheme` inherits the attribute works on any
|
|
27
|
+
* element, not only on the root: put it on a panel for a light preview inside
|
|
28
|
+
* a dark page.
|
|
29
|
+
*
|
|
30
|
+
* `system` removes the attribute rather than writing out whichever mode is
|
|
31
|
+
* current, which would freeze the page there the moment the reader's machine
|
|
32
|
+
* changed.
|
|
33
|
+
*/
|
|
34
|
+
const cycle = () => {
|
|
35
|
+
const next = MODES[(MODES.indexOf(mode) + 1) % MODES.length]
|
|
36
|
+
setMode(next)
|
|
37
|
+
if (next === 'system') {
|
|
38
|
+
delete document.documentElement.dataset.colorMode
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
document.documentElement.dataset.colorMode = next
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<helix-app-header material="glass" sticky>
|
|
46
|
+
{/* The wordmark is the way back to the first page, which is what a reader
|
|
47
|
+
expects of one, so there is no third destination in the bar saying the
|
|
48
|
+
same thing. It is also the only name of the project on the screen: the
|
|
49
|
+
landing page leads with what this is rather than with what you called
|
|
50
|
+
it.
|
|
51
|
+
|
|
52
|
+
The mark is a placeholder for your own, and it is drawn in
|
|
53
|
+
`--helix-color-brand-mark`, the token for the brand as a graphic. */}
|
|
54
|
+
<Link className="wordmark" data-link="quiet" href="/" slot="start">
|
|
55
|
+
<helix-icon name="cube" />
|
|
56
|
+
<helix-text variant="body-strong">__PROJECT_NAME__</helix-text>
|
|
57
|
+
</Link>
|
|
58
|
+
|
|
59
|
+
{/* The links are yours. Navigation owns the marker that travels between
|
|
60
|
+
them and renders no anchors of its own, because a router renders its
|
|
61
|
+
own and could not render them inside this shadow root. The base layer
|
|
62
|
+
styles them, `aria-current="page"` included. */}
|
|
63
|
+
<helix-navigation label="Pages">
|
|
64
|
+
<Link aria-current={path === '/overview' ? 'page' : undefined} href="/overview">
|
|
65
|
+
Overview
|
|
66
|
+
</Link>
|
|
67
|
+
<Link aria-current={path === '/join' ? 'page' : undefined} href="/join">
|
|
68
|
+
Join
|
|
69
|
+
</Link>
|
|
70
|
+
</helix-navigation>
|
|
71
|
+
|
|
72
|
+
{/* An icon with no words has no accessible name unless it is given one,
|
|
73
|
+
which is the whole reason this is its own element rather than a button
|
|
74
|
+
with a glyph in it. */}
|
|
75
|
+
<helix-icon-button
|
|
76
|
+
label={`Color mode: ${mode}`}
|
|
77
|
+
onClick={cycle}
|
|
78
|
+
slot="end"
|
|
79
|
+
title={`Color mode: ${mode}`}
|
|
80
|
+
>
|
|
81
|
+
<helix-icon name={MARKS[mode]} />
|
|
82
|
+
</helix-icon-button>
|
|
83
|
+
</helix-app-header>
|
|
84
|
+
)
|
|
85
|
+
}
|
package/templates.json
CHANGED
|
@@ -2,21 +2,21 @@
|
|
|
2
2
|
"placeholder": "__PROJECT_NAME__",
|
|
3
3
|
"templates": [
|
|
4
4
|
{
|
|
5
|
-
"description": "React and TypeScript on Vite. The shortest path to a running
|
|
5
|
+
"description": "React and TypeScript on Vite. The shortest path to a running app.",
|
|
6
6
|
"id": "react-vite",
|
|
7
7
|
"named": [
|
|
8
8
|
"index.html",
|
|
9
9
|
"package.json",
|
|
10
|
-
"src/
|
|
10
|
+
"src/site-header.tsx"
|
|
11
11
|
],
|
|
12
12
|
"title": "React (Vite)"
|
|
13
13
|
},
|
|
14
14
|
{
|
|
15
|
-
"description": "React and TypeScript on the Next.js App Router, with the
|
|
15
|
+
"description": "React and TypeScript on the Next.js App Router, with the three pages as real routes.",
|
|
16
16
|
"id": "react-nextjs",
|
|
17
17
|
"named": [
|
|
18
18
|
"app/layout.tsx",
|
|
19
|
-
"app/
|
|
19
|
+
"app/site-header.tsx",
|
|
20
20
|
"package.json"
|
|
21
21
|
],
|
|
22
22
|
"title": "React (Next.js)"
|