Collapsible
A trigger and a region it shows and hides — and no JavaScript anywhere in it.
src/components/rahti_ui/collapsible.rs
Basic
A disclosure. The trigger comes first, and everything after it is the content.
Can I use this in my project?
shadcn's documented example, and the composition it uses: a trigger, then a content region, as siblings. That maps onto `<details>` and `<summary>` exactly — which is what this component is.
Click it with JavaScript disabled and it still opens. This is the only component in the library other than the Slider that is fully *operable* with the runtime absent, rather than merely drawn: the alternative port is a `<div>`, a `<button>` and a script, and that page would show a trigger that does nothing.
The chevron turns because of `TRIGGER_ICON`, which is `[[open]>summary_&]:rotate-180` — the icon reads the `open` attribute off its own `<details>`. Radix publishes `data-state="open"` for this; there is no `data-state` here, because nothing would keep a copy of the state true once the browser started changing it.
Starred repositories
shadcn's own demo, and the shape that had to move.
@peduarte starred 3 repositories
Toggle@radix-ui/primitives
@peduarte starred 3 repositories
Toggleshadcn's headline demo, and the one call site in this port that had to be rearranged rather than translated. shadcn puts the trigger inside a header `<div>` and then an always-visible row *between* the trigger and the content. A `<summary>` is the first child of its `<details>` and everything after it is disclosed, so there is nowhere for that row to sit — it moves inside the summary.
Which is arguably the better widget: the whole header is now the target rather than an eight-pixel icon, which is what a user reaching for it expects anyway. But it is a different call site, and it is the honest cost of the element.
The ghost button is a `<span>` carrying `button_variants(…)`, not a `<button>`. A button inside a summary is interactive content inside interactive content — two tab stops where the user sees one — so shadcn's `asChild` Button crosses as a class list instead of as an element.
As a button
shadcn's `asChild` Button trigger, as the class list it always was.
Shipping details
The trigger takes the Button's class list. Everything a Button *looks* like crosses; what does not cross is the element, and nothing here needed it — the focus ring, the hover, the disabled treatment and the icon sizing are all in `button_variants(…)`.
Two utilities of the component's own are underneath: `list-none` and `[&::-webkit-details-marker]:hidden`, which take off the UA's disclosure triangle in two different engines, and `cursor-pointer`, which is what a button conveys and a summary does not. That is the entire class list this component owns.
Starts open
An attribute, not an effect.
Open on arrival
`open` alone is Radix's `defaultOpen`: the server writes the attribute and the browser owns it from then on. `open={…}` is Radix's controlled `open` — the same distinction React draws, and the one HTML has always drawn. See "Controlled" below.
Exclusive
A set that opens one at a time, with nothing coordinating them.
What is rahti-ui?
Does it need JavaScript?
Can I use my own classes?
`name="faq"` on each one. Every `<details>` sharing a name is a radio group: opening one closes the others. There is no state, no script and no shared context — it is an HTML attribute, and it is why the Accordion that comes after this component is mostly a class list.
The name is scoped to the document rather than to a container, so two unrelated groups on one page need two names.
Animated
The height transition, opted into, and only where the browser has it.
With a height transition
The transition runs on `::details-content`, the browser's own box around everything after the summary — the element Radix had to fake by keeping hidden content mounted.
`interpolate-size: allow-keywords` is what lets a height transition end at `auto`, and `content-visibility` carries `allow-discrete` so the content stays rendered for the length of the close.
`animated` is off by default, because shadcn ships no animation for this component. `tw-animate-css` does carry `animate-collapsible-down`, but those keyframes end at `var(--radix-collapsible-content-height, auto)` — and there is no Radix here to publish it.
This is progressive enhancement and nothing about it is guaranteed. A browser without `interpolate-size` or `::details-content` ignores every declaration and the collapsible snaps open exactly as the ones above do — which is why it is a prop rather than a default.
Controlled
Radix's `open` and `onOpenChange`, as an attribute and an event.
Driven by the page
Driven through the tag, one way
Two collapsibles, one value. The first is built with `collapsible().bind("open", "expanded").on("toggle", …).render(…)` and interpolated into this page's block; the second is the `<Collapsible open={expanded}>` tag. The button drives both. Only the first reports back — click its summary and the readout follows, then click the second's and watch it not.
The difference is the boundary. A component's root is a PulsePoint boundary, and a boundary reads every `{…}` attribute as a value expression evaluated in the parent's scope — which is why `open={expanded}` works through the tag. A native `on*` handler is only ever attached to an ordinary element, so `ontoggle={…}` on the tag is read as a value too, evaluated during render, and fails on the `target` that is not there.
`render` returns the `<details>` with no boundary around it, so interpolating it here puts both the binding and the handler in this block's scope. The Switch's page takes the same route for the same reason — and it is also why this section is written inline in `page()` rather than in a helper: a helper's `html!` is a scope of its own and could not read `expanded`.
Both of these are open when the parser reaches them, whatever `expanded` says. A binding is the literal text `open="{expanded}"` in the server's markup, and an `open` attribute carrying any value is an open disclosure. HTML fires `toggle` at load for one it found open, so the first collapsible's `ontoggle` runs once as ordinary inline JavaScript before the runtime claims it — in a global scope, where `setExpanded` does not exist. That is what the `typeof` guard in the handler is for, and why `expanded` is seeded `true`: the guard keeps the load-time run harmless, and the seed keeps the parser's guess right so neither one flashes on the way to its real state.
Disabled
A trigger that is drawn and does not toggle.
Not today
A `<summary>` has no `disabled` attribute, so this is two things at once: `pointer-events-none` blocks the click and `tabindex="-1"` takes the keyboard away. Without the second, `Enter` on a focused summary still opens it. `aria-disabled` says so out loud.
Radix puts `disabled` on the root and the trigger reads it through context. Here it belongs to the trigger, because the trigger is the only part that can act on it — and the look is borrowed from the Button's own `disabled:opacity-50` rather than invented, since Radix supplies none.
Nested
One inside another, and the chevron that knows which is which.
Outer, open
Inner, closed
Look at the two chevrons. The outer collapsible is open and the inner one is not, and each icon is turned by its own disclosure — because `TRIGGER_ICON` is `[[open]>summary_&]` and not `[[open]_&]`. A descendant combinator would turn the inner chevron the moment the outer one opened.
Same rule, and the same reason, as the Resizable's handle reading its group's `aria-orientation` through a `>`. A part that styles itself from an ancestor's state has to name which ancestor, and the nearest one is the only answer that survives nesting.
Built in Rust
Disclosures a page assembles rather than writes.
Build
Check
Test
Three collapsibles built with `collapsible().render(…)` and `Html::concat` rather than written as tags. Every part renders the same outside a tag as inside one, because none of them has a script to leave out — which is not true of most of this library.
The port
shadcn ships no class strings for this component, so all of it is behaviour.
What is not ported: `data-state`, because nothing here would keep a copy of the state true — select on `[open]` instead. And free composition, because a `<summary>` is the first child of its `<details>` and everything after it is the content. Those two are the whole bill.
What is gained: the widget works with the runtime absent, find-in-page opens a closed one to show a match inside it, a print stylesheet can expand all of them, and the accessibility is the browser's rather than this library's.
The boundary
The component, written out as the HTML it already was.
Written as plain HTML
`collapsible_trigger_variants(…)` on a page's own `<summary>`. This is the clearest statement of what the component is: three `data-slot`s and two utilities over markup the browser already understands. Every other component in this library loses something when it is written out by hand; this one loses nothing.
The rule for bindings is inverted here compared with everywhere else. A binding on a component's own root usually resolves in the parent scope and finds nothing useful — the trap. On a Collapsible it resolves in the parent scope and that is exactly right, because the parent is the page holding the state and this component contributes no scope in between.