handterm-leptos (0.0.1)
Installation
[registries.forgejo]
index = "sparse+ " # Sparse index
# index = " " # Git
[net]
git-fetch-with-cli = truecargo add handterm-leptos@0.0.1 --registry forgejoAbout this package
handterm-leptos
Typed Leptos components for handterm.
handterm is CSS first. Tier 0 and Tier 1 components are classes over markup you write, and Tier 2 components are light-DOM custom elements that enhance markup you write — which is why there is no React binding, no Vue binding, and no runtime. This crate is the one place that knowledge is written down for a language: a component per shipped component, rendering the markup the library documents, so a mistyped class is a compile error rather than something that quietly renders unstyled.
use handterm_leptos::prelude::*;
use leptos::prelude::*;
#[component]
fn Panel() -> impl IntoView {
view! {
<Card>
<CardHeader>
<CardTitle>"Air recycler"</CardTitle>
<Badge variant=BadgeVariant::Ok>"Nominal"</Badge>
</CardHeader>
<CardBody>"Scrubber stack at 96% efficiency."</CardBody>
</Card>
}
}
Install
Published to a private Forgejo registry, which cargo has to be told about by name:
# .cargo/config.toml
[registries.handterm]
index = "sparse+https://git.shiverpeak.xyz/api/packages/alex/cargo/"
# Cargo.toml
[dependencies]
handterm-leptos = { version = "0.0.1", registry = "handterm" }
Reading the registry needs no token. Enable a renderer feature on leptos in your own
binary, not here — see below.
What it does not do
It ships no CSS. @handterm/tokens and @handterm/css are still the product, and
@handterm/elements still has to load for the Tier 2 components to be anything but static
markup. Add them the way any consumer would:
pnpm add @handterm/tokens @handterm/css @handterm/elements
<html lang="en" data-faction="mcrn" data-scheme="dark">
<head>
<link data-trunk rel="css" href="node_modules/@handterm/tokens/dist/tokens.css" />
<link data-trunk rel="css" href="node_modules/@handterm/css/dist/handterm.css" />
<link
data-trunk
rel="copy-dir"
href="node_modules/@handterm/elements/dist"
data-target-path="elements"
/>
<script type="module" src="/elements/index.js"></script>
</head>
</html>
crates/showcase is that wiring in full, and renders every component.
handterm-assets is the same three packages' built output as a crate, for a project that
would rather not have a Node toolchain and a second registry in it. Same bytes, same
ordering rules; it serves them, this crate writes the markup.
It picks no renderer. No csr, ssr or hydrate feature is enabled here — whether you
build for the browser, the server or both stays your binary's decision.
Conventions
Three rules, so knowing one component is knowing all of them.
class adds. Each component writes the classes that make it what it is and appends
yours. <Badge class="wide"> renders class="ht-badge wide"; it never replaces.
Variants are enums, and the default writes no attribute. handterm's CSS reads a missing
data-variant as its default styling, so ButtonVariant::Solid emits nothing at all rather
than a second name for the same thing. Every prop takes a value or a signal.
Everything else is Leptos. Each component renders one root element, so {..} spreads
ids, form attributes and event handlers straight onto it.
# use handterm_leptos::prelude::*;
# use leptos::prelude::*;
# fn view() -> impl IntoView {
view! { <Button {..} id="engage" on:click=move |_| ()>"Engage"</Button> }
# }
One wrinkle, and it is Rust's rather than this crate's: a prop whose value is a path,
written immediately before {..}, parses as a struct literal and fails to compile.
Parenthesise it — variant=(ButtonVariant::Danger) {..} — and put every prop before the
spread. unused_parens then calls those parentheses redundant, because they are gone by the
time it looks, so allow the lint on modules that use both together.
The custom elements
Tabs, Combobox, MultiSelect, Calendar, Menu, Toaster, Toolbar and Field are
generated from custom-elements.json, which is itself generated from the elements' JSDoc.
Their attributes, their events and their documentation cannot drift from the elements they
wrap, because none of it is typed twice.
They take state as props and report changes through on_* callbacks, which is the whole of
those elements' contract — state is attributes, changes are events, and no imperative handle
is ever needed:
# use handterm_leptos::prelude::*;
# use leptos::prelude::*;
# fn view() -> impl IntoView {
let (showing, set_showing) = signal(String::from("drive"));
view! {
<Tabs value=showing on_change=move |_: CustomEvent| set_showing.set("comms".into())>
<TabsList label="Ship systems">
<Tab value="reactor">"Reactor"</Tab>
<Tab value="drive">"Drive"</Tab>
</TabsList>
<TabPanel value="reactor">"Core temperature nominal."</TabPanel>
<TabPanel value="drive">"Sustained burn."</TabPanel>
</Tabs>
}
# }
The inner markup is yours: these elements attach behaviour to what you render rather than
rendering it for you, which is what makes them work under SSR with no flash and no
hydration mismatch. The part components — TabsList, Tab, TabPanel and their
equivalents — write that markup.
To regenerate after changing an element:
pnpm run gen:leptos # write the wrappers
pnpm run gen:leptos:check # fail if what is committed is stale (CI runs this)
Dependencies
| ID | Version |
|---|---|
| leptos | ^0.8.20 |
| leptos | ^0.8.20 |