Toggle Group
A strip of toggles: one at a time, or any number at once.
src/components/rahti_ui/toggle_group.rs
Single — one at a time
The items are radios sharing one `name`, so the browser does the whole of it: one selection, arrow keys that move it, one tab stop for the set rather than three, and "1 of 3" read out because the group is a `radiogroup`. Radix implements every line of that; here `type="single"` is not a mode, it is the input type.
Multiple — any number at once
The items are checkboxes, which is what a row of Toggles already was. Each carries its own `name` rather than sharing the group's, because that is how a form reads several independent answers — and it is why `.multiple()` takes no name where `.single(…)` requires one.
Variants
shadcn's two, reached through the Toggle they are defined on. The outline group is where the strip does its work: every item after the first has `border-l-0`, so the shared edges are one line rather than two, and only the first and last corners are rounded.
Sizes
shadcn's three, and an item keeps only the height of the one it is given. A group overrides `min-w-*` with `min-w-0` and `px-*` with `px-3`, and shadcn resolves that with `twMerge` — this library joins its layers instead, so the two utilities are never joined in and there is nothing left to beat. Same rendered class list, and the audit can prove it.
Spacing
`0` is shadcn's default and the one with all the work in it: the items are joined into a strip, and the corners come from `first:rounded-l-md` and `last:rounded-r-md`. Those are `:first-child` and `:last-child` selectors, so every item has to be a real child of the group — which is why an item renders as one `<label>` even when it carries a binding, with its script inside the label rather than beside it. Any other spacing turns the utilities off and each item keeps its own corners.
With text
What sits between the tags is what the item shows, laid out by the Toggle's `inline-flex items-center gap-2`. `min-w-0` and `px-3` are the group's own — an item is as wide as it needs to be rather than square, which is the difference between a toolbar button and a labelled one.
States
`disabled` is per item, and it is the input that carries it — the label's `has-[:disabled]:opacity-50` is a question about the control inside it. Every state utility on this component works that way, which is the Toggle's story rather than this one's.
The middle item is disabled — and the arrow keys skip it, because a disabled radio is not in the group's rotation. Nothing here arranged that.
As a form posts it
The two types post differently, and it is HTML's difference rather than this component's. A single group is one `name` with one value — `?align=center`. A multiple group is a key per item, and a checkbox that is off posts nothing at all — `?bold=on` with `italic` simply absent. No hidden fields, no JavaScript, and nothing serialising an array.
The context, written down
shadcn's Root puts `variant`, `size` and `spacing` in a React context and its items read them out. Children here arrive already rendered, so a group cannot reach into them — the same wall the Radio Group and the Select hit. `group.item()` is the answer: it hands out an item with the group's own type, name, variant, size and spacing already set, so the call site says each of them once. The tags spell them out instead, which is what a tag is for.
let group = toggle_group().single("ctx")
.variant(ToggleVariant::Outline)
.size(ToggleSize::Sm);
group.render(Html::concat([
group.item().value("a").pressed(true).render(…),
group.item().value("b").render(…),
]))Class override
`class` is merged, not appended, on either element. The second group is the case worth reading: the pressed colours are `has-[:checked]:` utilities, so an override that means to recolour the pressed state has to carry the same prefix — a plain `bg-emerald-600` would sit beside them and lose. The merge is `twMerge`'s, in the browser, so both of these are unstyled until PulsePoint mounts.
What this port changed
shadcn's ToggleGroup is a Radix Root with a `type` mode, a React context feeding its items, and roving-focus code so the arrow keys move between them. Here `type` is not a mode — it is the input type, and the browser supplies the mode.
| shadcn | here | why |
|---|---|---|
| type="single" | <input type="radio"> | A single-select toggle group is a radio group wearing shadcn's paint. One selection, arrow keys, one tab stop, a value in the post — none of it written. |
| type="multiple" | <input type="checkbox"> | Which is what a row of Toggles already was. Each item posts under its own name. |
| ToggleGroupContext | group.item() | Children arrive already rendered, so a group cannot reach into them. The builder hands out items with the group's own settings instead — the same answer the Radio Group and the Select give. |
| twMerge(size, "min-w-0 px-3") | the height alone | shadcn layers two utilities over a size that already set them and merges. This joins, so the overridden pair is never joined in — same result, and the audit can prove there is nothing to resolve. |
| — (a Root and its items) | a script inside the label | The corners are `:first-child` and `:last-child`. An item carrying a binding needs a script, and a script beside the label would be a child of the group and take a corner — so it goes inside the label, which is valid HTML and invisible to layout. |
Everything else is shadcn's, copied rather than reinterpreted — including one utility shadcn is not using: `data-[spacing=default]:data-[variant=outline]:shadow-xs` on the group never matches, because `spacing` is a number and `data-spacing` is therefore never the string `default`. It is carried here unchanged rather than quietly dropped.
The component tags, and their boundary
The tags are the right call for a group this page does not drive — a `type`, a `name`, a `value`, a `pressed` default. What a plain form posts, the tags render.
Where the tag stops
A binding does not cross the boundary: it is compiled in the scope the element is mounted in, which through a tag is the component's.
// Compiles against a scope with no `view`, and does nothing.
<ToggleGroupItem checked={view === 'list'} value="list" />And a tag cannot be told what the group knows, which is the whole of `the context, written down` above: `type`, `name`, `variant`, `size` and `spacing` are written on every item because children arrive already rendered. A group the page drives wants the builder, interpolated — which is what the section below this one does.
Controlled, page scope
`change` bubbles from the inputs inside, so one handler on the group serves every item — which is the nearest thing this port has to Radix's `onValueChange`. A single group reports through `target.value`, because its items are radios and only the one being turned on fires the event.
Showing: {view}