Repository navigation
v0.2.0
Require aurora 0.1.45: the context primitive the parts are built on
Add AlertDialogPortal, the last part upstream exports
Split NavigationMenu and Sidebar into their upstream parts
NavigationMenu keeps one surface for the whole bar, as Menubar does: only one
panel is ever open, and moving between triggers is a change of anchor and
contents rather than a close and an open. Its delays are what make it usable
with a pointer — opening waits so crossing the bar does not flash every panel,
closing waits longer so travelling diagonally into a panel does not dismiss it
halfway. NavigationMenuViewport is a NAMED DEVIATION: inert, because the
panel is portalled and positioned by floatingSurface rather than drawn into
a viewport element the way Radix animates its width and height.
Sidebar goes from eleven exports to twenty-four, and one of them is a rename
that had to happen: what nebula called SidebarMenuItem was upstream's
SidebarMenuButton — the control — while upstream's SidebarMenuItem is the
<li> that holds it, an action and a badge. Keeping the old name would have
made every layout copied from upstream silently wrong.
SidebarProvider and useSidebar are new, and the state stays in the
cookie-backed module singleton behind them: the shell renders once and a
trigger may sit in a header that is not inside the provider's subtree. The
cookie is still what makes the server render the right width — with
localStorage the sidebar renders expanded and snaps shut on hydration, on
every page load.
Also new: the rail, the inset, the input, the separator, the group action and
content, the menu badge, the skeleton and the sub-button — the parts an
application shell is actually assembled from.
Split Command into its nine upstream parts
Filtering happens WITHOUT rebuilding the list. Each item registers itself as
it is built and then hides itself when the query stops matching — Aurora never
re-runs a component's setup, so a palette that rebuilt its results on every
keystroke would lose the DOM its highlight and its scroll position live in. A
group hides itself the same way once none of its own items are showing, so an
empty heading never sits over nothing.
That changes what a test counts: the filtered-out options are still in the
document, behind hidden, which also takes them out of the accessibility tree.
One bug found by making the change, and it was invisible in isolation. The
palette seeded its highlight by WRITING a signal while its items registered —
a write during the render that was building them. A Combobox rebuilds its
content inside an effect, so the write re-entered that effect and the palette
recursed until the stack gave out; a Command mounted on its own has no
enclosing effect to re-enter and never showed it. The highlight is derived now:
the explicit value when the user has moved, otherwise the first match. A
regression test opens a Combobox.
Combobox and CommandDialog compose the parts rather than passing an array
through. Combobox gathers consecutive options sharing a group under one
heading, in the order given — a group that repeats after an interruption gets a
second heading, because reordering the caller's list to merge them would change
which option the arrows reach next.
Split Pagination, Carousel, Resizable and InputOTP into their parts
Four components whose descriptions had each fixed something the composition
leaves open:
- Pagination described a page LIST.
pageWindowstill decides which pages are
shown — first, last, a run around the current one and the gaps — and is
still exported, because that is the part worth having; DataTable now
composes the parts around it. A link stays an<a>whether or not it has an
href, andonSelectintercepts only when given, so a server-rendered
pagination keeps working with JavaScript off. - Carousel took an array of slides. Each
CarouselItemnow claims its
position as it is built, and reads the total lazily — the count is only
final once every slide exists, so "3 of 7" cannot be captured early. - Resizable becomes upstream's
ResizablePanelGroup/ResizablePanel/
ResizableHandle, with a NAMED DEVIATION: the group holds one split, so the
first panel takes the share and the second takes the rest.
react-resizable-panelsdistributes an array across any number of them. - InputOTP's boxes claim their own index, so the slots need no index prop and
cannot be numbered wrongly. A real<input>per box stays: it is what gives
each one its own caret and the one-time-code hint the platform fills from an
SMS.
Complete Field and InputGroup against their upstream parts
Field had four exports where upstream has ten, and the six missing ones were
the ones that carry the accessibility: a label with a real for, a description
and an error whose ids the control names, a legend and a fieldset for a group
of fields that answer one question together.
fieldIds() stays and is now shared through context rather than threaded into
every part. Upstream leaves the ids to the caller — <FieldLabel htmlFor="x">
beside <Input id="x"> — which works until the two drift; here the caller
needs the set only for the CONTROL, and the label, the description and the
error read it themselves. The error node is always in the DOM behind
empty:hidden, because the id describedBy names has to resolve to something
the moment an error appears and a node added later is announced late or not at
all.
InputGroup gains the button, the static text and the textarea, plus align on
the addon so one can sit above or below the control rather than only beside it.
The button defaults to ghost and the smallest size: a filled button the height
of the field turns the group into two controls side by side, which is exactly
what the group exists to avoid looking like.
Split AlertDialog and Drawer into their upstream parts
AlertDialog keeps everything that distinguishes it from a Dialog, all of which
is about refusing to let the question be dismissed by accident: role ="alertdialog", no outside-click dismissal, and focus landing on CANCEL rather
than on the confirming action — the safe choice is the one a hurried Enter
should hit. Escape cancels rather than confirms, for the same reason.
AlertDialogMedia and the size on the content are new; upstream's header
grid rearranges around both, which is why the classes carry
has-data-[slot=alert-dialog-media].
Drawer keeps its drag-to-dismiss, on distance OR velocity: a slow long drag
and a quick flick mean the same thing, and requiring distance alone makes a
flick feel ignored. The handle stays decorative and aria-hidden — everything
the gesture does, Escape and the backdrop already do, because a gesture must
never be the only way out of a modal surface.
Split ContextMenu and Menubar, on rows shared with DropdownMenu
Upstream repeats all fifteen row components in each of dropdown-menu.tsx,
context-menu.tsx and menubar.tsx, because each file is copied on its own and
has to stand alone. The three already share primitives/menu.ts here — its
class strings, its keyboard model, its submenu registry — and the registry
copies it with them, so createMenuParts(prefix, context) builds the rows once
and the three menus differ by exactly what differs between them: the
data-slot prefix and which context tells a row how to close the menu it is
in. DropdownMenu was rewritten onto it, and its sixty tests did not move.
What is specific survived:
- ContextMenu opens at the POINTER, so a zero-size anchor is parked at the
click coordinates and the surface positioned against that — which is what
lets the menu flip and shift off a viewport edge like any other. Touch keeps
its long press, cancelled by movement so a scroll that starts on the region
does not open a menu under the thumb. - Menubar keeps ONE surface for the whole bar. Only one menu is ever open, and
moving between them is a change of anchor and contents rather than a close
and an open — that is what makes left/right feel like walking a bar. Each
MenubarMenuclaims an index as it is built, which is the order the arrows
walk, and provides it to its trigger and its content so neither has to be
told which menu it belongs to.
Split RadioGroup, ToggleGroup and HoverCard into their parts
RadioGroup keeps its named deviation and gains a reason to state it plainly:
upstream renders a button with role="radio" because Radix needs an element
it fully controls, and that costs arrow-key movement, single-checked
enforcement and posting with a plain HTML form — all of which a real
<input type="radio"> sharing a name does for free. The input stays; the
slot names and the indicator are upstream's.
ToggleGroup gains upstream's spacing, which decides whether the items join
into one bar or sit apart, and passes variant and size down through the
context so a grouped button cannot drift from a standalone Toggle.
HoverCard's trigger is an <a> when given an href and a <span> otherwise.
Upstream wraps a link with asChild; a <span> around one would put the hover
target and the link in different elements, and the card would open from
something the keyboard never reaches.
Split Tabs, Accordion, Collapsible, Avatar and Breadcrumb into parts
Collapsible's own doc-comment named the reason it was not composed: "Aurora
has no context". It does now, so the deviation is gone rather than explained.
Each conversion keeps what the description was doing and loses only the
description:
- Tabs mints one id pair per value, so a trigger and its panel — built at
different moments, knowing only the value they share — name the same pair.
The implicit default is the first ENABLED trigger, since a disabled tab
selected by default shows a panel nothing can navigate back to. Upstream's
tabsListVariantscomes with it: the underlinedlinelook nebula had no
way to ask for. - Accordion keeps the open SET in the group, which is what
type: "single"
needs and what a per-item state cannot express. Its item provides a second
context so the trigger and the panel share ids without the caller repeating
the value. - Avatar is the clearest case for context: three load states, reported by the
image and read by the fallback, neither knowing the other exists. The
fallback still waits for a real failure rather than appearing while the image
loads — the flicker the component exists to avoid — and an avatar with no
image at all no longer waits for a load that will never happen.AvatarBadge,
AvatarGroupandAvatarGroupCountare new. - Breadcrumb and its seven parts are purely presentational, so none of them
takesParts: there is no context here to be inside of.
Split Select into its ten upstream parts
The options were an options: SelectOption[] prop, which fixed what an option
could be: a string label, optionally disabled, optionally under a heading. An
item is now a component and takes any children at all.
The keyboard model did not follow the DOM before and still does not — the
listbox pattern keeps focus on the trigger and only moves
aria-activedescendant, so there is no focused element to walk from. Instead
each SelectItem REGISTERS itself as it is built, in document order, and the
arrows, Home/End and type-ahead walk that list exactly as they walked the
prop.
Type-ahead needs text, and an item built from markup has none a component can
read before it is in the DOM. textValue says what to match, defaulting to the
children when they are a plain string — Radix's rule, and for its reason.
The option ids are held PER SELECT. A module-level map keyed by value would
hand two selects offering the same value one id, and every trigger's
aria-activedescendant would then point at the first select's option. A test
pins it.
Scroll buttons are new. Upstream shows them because Radix's item-aligned
position hides the native scrollbar; the panel here scrolls natively, so they
are an affordance rather than the only way down, and that is why they scroll
while hovered and need no keyboard equivalent.
Split DropdownMenu into its fifteen upstream parts
The menu was data-driven: one entries: MenuEntry[] prop that menuPanel
rendered. Upstream composes instead, and the difference is not only shape — a
description can only express what its type anticipated, while a composed row
takes any children at all.
The keyboard model did not have to change, and that is the point. wireMenu
works on the RENDERED DOM — the [data-nebula-item] rows and their role —
never on the description that produced it, so roving focus, type-ahead, arrows,
Home/End and submenu traversal all carried over untouched.
Submenus needed one change. A row's nested entries were stashed in a map and
turned into a panel by menuPanel on open; they now carry a panel FACTORY
instead, so a composed DropdownMenuSubContent hands over markup it built
itself and the entries-based path supplies a factory that still calls
menuPanel. wireMenu cannot tell the two apart, which is what keeps the
existing API working.
Upstream's class strings now live in primitives/menu.ts rather than being
repeated per component. shadcn repeats them across dropdown-menu, context-menu
and menubar because each file is copied alone; all three already depend on this
module here and the registry copies it with them, so repeating them would only
be three places to update when one changes. That also brings in what nebula was
missing: data-[inset], data-[variant=destructive], and the transform origin.
Split Popover, Dialog and Sheet into their upstream parts
Same move as Tooltip, and the same reason: the composition is the API. Popover
gains its anchor, header, title and description; Dialog its close, overlay,
portal, header, footer, title and description; Sheet the same set pinned to an
edge.
Two things this surfaced that Tooltip had not.
A part that READS context has to be built eagerly too, so it must not be
hidden inside a Slot — the renderer calls a Slot long after setup returned,
and the part then finds nothing. The rule is now one line in Parts: () =>
for a component that PROVIDES, a plain value everywhere else. A test pins it by
holding the thunk a Slot would hold and calling it afterwards, since an error
thrown inside a render effect is not something an assertion can see.
DialogContent and SheetContent build their children during setup rather
than at open. That is not only the context rule: the panel names itself with
the title's id, so a panel rendered before the title registered would have had
nothing to point at. A missing title now warns, as upstream warns, instead of
being a type error on a title prop that no longer exists.
aria-describedby is omitted when there is no description, rather than
pointing at an element that does not exist — a screen reader announces neither,
and nothing says why.
Sheet loses its sheet-body wrapper: upstream puts the padding on the header
and footer and leaves the middle to the caller, and an extra flex child does
not sit where a design copied from upstream expects it. Sidebar, its only
consumer, already scrolls its own body.
DialogPortal does what its name says rather than standing in as a
passthrough, even though DialogContent does not need it — Dialog portals
the whole overlay through modalSurface.
Split Tooltip into its four upstream parts
Upstream is TooltipProvider / Tooltip / TooltipTrigger /
TooltipContent; nebula had one function taking trigger and content as
props. The difference is not cosmetic — a call site shaped like the thing it
builds is the whole point of the composition — but it needs a way for a parent
to share state with parts it does not pass props to, and until aurora gained a
context primitive there was none.
The state sits in Tooltip and both ends read it through context, so the
trigger can describe content it never sees. TooltipContent registers what to
build and renders nothing where it is written, because the surface is portalled
out of the tree when it opens. Both are built inside Tooltip's setup, which
is what puts them within reach of the context: aurora evaluates eagerly, so
children handed over already built would have run before the provider existed.
The Parts type names that requirement.
Three gaps closed along the way, all of which every anchored surface shares:
--nebula-transform-origin, sozoom-in-95expands out of the control that
opened the surface rather than out of its own middle. Upstream reads the
Radix equivalent throughorigin-(…)on popover, dropdown, select and
tooltip alike.slideInFromSide, upstream's fourdata-[side=…]:slide-in-from-…, which
nebula had nowhere.- The arrow.
floatingcomputed an arrow offset andfloatingSurfacenever
forwarded it, so the option existed at both ends and was wired at neither.
Two deviations are named in the source: the wrapping <span> on the trigger,
because asChild needs an element to merge into and aurora has none until
render; and a default TooltipProvider where Radix raises, since the default
group is the page-wide window nebula already had.
The hover delay now defaults to 0, which is shadcn's delayDuration.
Changes since v0.1.9.