@fusion.dev/helix-templates 0.34.2 → 0.35.0

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.
@@ -0,0 +1,191 @@
1
+ import Link from 'next/link'
2
+ import { roster } from './data'
3
+ import { DOCS } from './docs'
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>&lt;form&gt;</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
+ }
@@ -4,12 +4,17 @@ import type { ReactNode } from 'react'
4
4
  /* Every layer in one import: the tokens, the reset, the utility layer, and the
5
5
  `:not(:defined)` rules that keep a tag from arriving unstyled. */
6
6
  import '@fusion.dev/helix-styles/helix.css'
7
+ /* After it, so a rule of your own that names the same thing wins. */
8
+ import './app.css'
7
9
 
8
10
  /* Extensionless, unlike the libraries this imports. Turbopack does not map a
9
11
  `.js` specifier back to the `.tsx` beside it, which is webpack's
10
12
  `extensionAlias` and has no equivalent there, so `./helix-elements.js` fails
11
- the first `next build` with "Module not found". */
13
+ the first `next build` with "Module not found". Every local import in this
14
+ template is written the same way for that reason. */
12
15
  import { HelixElements } from './helix-elements'
16
+ import { SiteFooter } from './site-footer'
17
+ import { SiteHeader } from './site-header'
13
18
 
14
19
  export const metadata: Metadata = { title: '__PROJECT_NAME__' }
15
20
  export const viewport: Viewport = { initialScale: 1, width: 'device-width' }
@@ -30,7 +35,14 @@ export default function RootLayout({ children }: { children: ReactNode }) {
30
35
  <html lang="en">
31
36
  <body>
32
37
  <HelixElements />
33
- {children}
38
+ {/* The bar, the route and the footer, in a column the height of the
39
+ window. `app.css` says why the footer is worth having and why the
40
+ column is flex. */}
41
+ <div className="shell">
42
+ <SiteHeader />
43
+ <main className="page">{children}</main>
44
+ <SiteFooter />
45
+ </div>
34
46
  </body>
35
47
  </html>
36
48
  )
@@ -0,0 +1,23 @@
1
+ import { Overview } from '../overview'
2
+
3
+ /**
4
+ * A server component, rendering custom elements directly.
5
+ *
6
+ * **The rows cross the boundary as a plain array**, which is worth saying because
7
+ * React drops object and function props on both halves of a server render: the
8
+ * server writes nothing for a prop whose value is an object, and the hydrate path
9
+ * applies none either. `HelixElement` assigns them itself once it upgrades, so
10
+ * the data view takes its data as an ordinary prop and this page needs no
11
+ * `'use client'`.
12
+ *
13
+ * **The cell values still do not server-render**, which is a different limit.
14
+ * Reading a cell needs the columns, and the columns are markup children the
15
+ * server render does not hand the table, so the first paint is a labeled shell.
16
+ * Reach for `helix-table` where the values have to be in the served HTML.
17
+ *
18
+ * What never crosses is a handler. Anything with one is a client component, the
19
+ * same as in any other React app, which is what `signup-form.tsx` is.
20
+ */
21
+ export default function Page() {
22
+ return <Overview />
23
+ }
@@ -0,0 +1,91 @@
1
+ import { members } from './data'
2
+ import { DOCS } from './docs'
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
+ }
@@ -1,18 +1,13 @@
1
+ import { Landing } from './landing'
2
+
1
3
  /**
2
- * A server component, rendering custom elements directly.
4
+ * The page a scaffold opens on, and a server component like the two beside it.
3
5
  *
4
- * Helix events are props: `onhelix-before-change` reaches `helix-before-change`.
5
- * Anything needing a handler is a client component, the same as any other React
6
- * app: this page needs none.
6
+ * Nothing here has a handler, so nothing here is marked `'use client'`. That is
7
+ * the line in an App Router project and it is not about Helix: the elements are
8
+ * registered once from the layout, and an unregistered tag in the served HTML is
9
+ * only an unknown element, which upgrades the moment the definition lands.
7
10
  */
8
11
  export default function Page() {
9
- return (
10
- <helix-flex align="center" direction="column" gap="lg">
11
- <helix-text as="h1">__PROJECT_NAME__</helix-text>
12
- <helix-text>Edit `app/page.tsx` and save.</helix-text>
13
- <helix-button emphasis="solid" tone="primary">
14
- Get started
15
- </helix-button>
16
- </helix-flex>
17
- )
12
+ return <Landing />
18
13
  }
@@ -0,0 +1,171 @@
1
+ 'use client'
2
+
3
+ /*
4
+ * **A handler is what makes this a client component.** A server component's
5
+ * output is serialized and a function cannot be, so anything with an `onSubmit`
6
+ * or an `onhelix-*` prop lives on this side of the line. The data view next door
7
+ * has neither, so it stays a server component.
8
+ */
9
+
10
+ import { type FormEvent, useState } from 'react'
11
+ import { teams } from './data'
12
+ import { DOCS } from './docs'
13
+
14
+ /** One message in the toast region, and what it says. */
15
+ interface Message {
16
+ readonly id: number
17
+ readonly text: string
18
+ }
19
+
20
+ /**
21
+ * The form.
22
+ *
23
+ * **Every control here is a form-associated custom element**, so a `<form>`
24
+ * collects them, resets them and validates them the way it does an `<input>`.
25
+ * That is what `ElementInternals` buys, and the submit below proves it: it reads
26
+ * the answers with `new FormData(form)` and nothing gathers them by hand.
27
+ *
28
+ * There is no wrapping `Field` component. A label in one shadow root cannot name
29
+ * a control in another, so every control renders its own `label`, `hint` and
30
+ * `feedback` instead.
31
+ */
32
+ export function SignupForm() {
33
+ const [handle, setHandle] = useState('')
34
+ const [messages, setMessages] = useState<readonly Message[]>([])
35
+
36
+ const join = (event: FormEvent<HTMLFormElement>) => {
37
+ /* The page answers this, so nothing navigates. Remove the line and the form
38
+ posts to the server like any other, with the same names. */
39
+ event.preventDefault()
40
+ const answers = [...new FormData(event.currentTarget).entries()]
41
+ .map(([field, value]) => `${field}=${value}`)
42
+ .join(' ')
43
+ setMessages((sent) => [...sent, { id: Date.now(), text: answers }])
44
+ }
45
+
46
+ return (
47
+ <div className="stack">
48
+ <header className="hero">
49
+ <helix-text emphasis="muted" variant="label">
50
+ Form
51
+ </helix-text>
52
+ <helix-text as="h1" variant="heading">
53
+ Join the roster
54
+ </helix-text>
55
+ <helix-text as="p" className="lede" emphasis="muted" variant="body">
56
+ Five controls, and a <helix-code>&lt;form&gt;</helix-code> reads every one of them the way
57
+ it reads an <helix-code>input</helix-code>.
58
+ </helix-text>
59
+ </header>
60
+
61
+ {/* The form takes a column of its own and the notes take what is left, so a
62
+ wide window is a page rather than a form with a void beside it. At 390
63
+ the two stack and the notes read as a postscript. */}
64
+ <div className="split">
65
+ <form className="form" onSubmit={join}>
66
+ <helix-input autocomplete="name" label="Name" name="name" required />
67
+
68
+ {/* Validation is the platform's: `type` and `required` are set on the
69
+ real input inside, and its own message is what shows once the reader
70
+ has touched the field. Set `feedback` to say it in your own words
71
+ instead, and it shows from the first paint rather than after a
72
+ mistake. */}
73
+ <helix-input
74
+ hint="Where we send the welcome note."
75
+ label="Email"
76
+ name="email"
77
+ required
78
+ type="email"
79
+ />
80
+
81
+ {/* **A controlled value, which is the one habit to unlearn.** The handler
82
+ is a prop named for the event, so `onhelix-before-change` hears
83
+ `helix-before-change`. A Helix element is not read-only: writing
84
+ `value` seeds it and the element goes on applying its own changes, so
85
+ taking the decision means refusing the change with `preventDefault()`
86
+ and then setting the value you want. Declining to call `setHandle` is
87
+ not a refusal, and leaves the element holding what you did not
88
+ accept. */}
89
+ <helix-input
90
+ hint="Lowercase and hyphens, whatever you type."
91
+ label="Handle"
92
+ name="handle"
93
+ onhelix-before-change={(event) => {
94
+ event.preventDefault()
95
+ setHandle(event.detail.value.toLowerCase().replaceAll(/[^a-z0-9-]/g, ''))
96
+ }}
97
+ value={handle}
98
+ />
99
+
100
+ <helix-select label="Team" name="team" placeholder="Pick one" required>
101
+ {teams.map((team) => (
102
+ <helix-option key={team} value={team}>
103
+ {team}
104
+ </helix-option>
105
+ ))}
106
+ </helix-select>
107
+
108
+ {/* A switch is a thing happening now rather than an answer a form asks
109
+ for, which is why it says on and off. It still submits, as `on`. */}
110
+ <helix-switch label="Send me the weekly digest" name="digest" />
111
+
112
+ <helix-button emphasis="solid" tone="primary" type="submit">
113
+ Join
114
+ </helix-button>
115
+ </form>
116
+
117
+ <aside className="notes">
118
+ {/* The one fact a reader needs before typing their name into a page
119
+ they have had for five minutes, which is why it is a callout and
120
+ not the fourth line of a paragraph. `tone` is the meaning and the
121
+ component decides what that looks like: a tint mixed from the
122
+ status color and a bar on the inline start, so it survives being
123
+ read in grayscale. */}
124
+ <helix-callout tone="info">
125
+ Nothing is posted anywhere. Submit it and the message shows exactly what a server would
126
+ have been handed.
127
+ </helix-callout>
128
+ <helix-text as="h2" variant="title">
129
+ What this is showing
130
+ </helix-text>
131
+ <helix-text as="p" emphasis="muted" variant="body">
132
+ Every control is a form-associated custom element, so the submit reads them with{' '}
133
+ <helix-code>new FormData(form)</helix-code> and nothing gathers the answers by hand.
134
+ Reset the form and they all go back, the way an <helix-code>input</helix-code> does.
135
+ </helix-text>
136
+ <helix-text as="p" emphasis="muted" variant="body">
137
+ Leave the email wrong and the complaint is the browser's own, in the reader's language.
138
+ Handle is the controlled one: it refuses the change and writes back what it will accept.
139
+ </helix-text>
140
+ <a data-link href={DOCS.forms}>
141
+ Forms
142
+ </a>
143
+ </aside>
144
+ </div>
145
+
146
+ {/* **The region is rendered whether or not there is anything in it.**
147
+ Assistive technology subscribes to a live region, and one that arrives
148
+ already holding its message is frequently never announced. Nothing
149
+ creates or destroys a message here either: the items are state, and a
150
+ dismissed one asks to go through `helix-dismiss`. */}
151
+ <helix-toast>
152
+ {messages.map((message) => (
153
+ <helix-toast-item
154
+ dismissible
155
+ duration={8000}
156
+ key={message.id}
157
+ onhelix-dismiss={() => {
158
+ setMessages((sent) => sent.filter((one) => one.id !== message.id))
159
+ }}
160
+ tone="success"
161
+ >
162
+ <helix-flex direction="column" gap="2xs">
163
+ <helix-text variant="body-strong">Read by the form</helix-text>
164
+ <helix-text variant="code">{message.text}</helix-text>
165
+ </helix-flex>
166
+ </helix-toast-item>
167
+ ))}
168
+ </helix-toast>
169
+ </div>
170
+ )
171
+ }
@@ -0,0 +1,38 @@
1
+ import { DOCS } from './docs'
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
+ }