All notable changes to sibujs-ui will be documented in this file.
This project follows Semantic Versioning.
- Bumped the
sibujsdependency to 3.2.2. No component changes — sibujs-ui does not use the router, so this is a maintenance bump to track the latest core release.
Components that teleport their content to document.body to escape overflow clipping registered their cleanup on the portaled node. Because dispose() only traverses the owner's subtree, a disposer on a node that has been moved to <body> never runs on unmount — so the portaled DOM node was orphaned in <body> indefinitely, and any document mousedown/keydown listeners and effect() subscriptions leaked with it.
Cleanup is now anchored on the in-tree root element (which dispose() does reach), so on unmount the positioning effect is torn down, global listeners are removed, and the portaled node is removed:
DropdownMenuContent— was leaking the node and (when open) the outside-click/keydown listeners; the positioning effect was also never disposed.DropdownMenuSubContentandMenubarSubContent— had no disposer at all; the portaled node and the open-state effect leaked on every unmount.TooltipContent(withportal: true) — already removed the node, but its hover timer and display effect leaked when portaled; both are now torn down too.
No API or behavior changes for mounted components — this only affects teardown. Verified against sibujs@3.2.0.
- Dev warning for a misplaced lone class string — a lone string passed to a component (
Skeleton("h-6 w-48")) still renders as a text child (unchanged behavior), but development builds now warn when that string looks like a CSS class list, hinting the{ class: "…" }form. This matches the coretag()behavior and prevents class names from silently rendering as visible text in styled wrappers. Prose strings (Badge("New"),Empty("No results")) never trigger the warning.
peerDependencies.sibujs bumped from ^2.0.0 to ^3.0.0 so the package installs cleanly alongside sibujs 3.x. sibujs-ui does not use ErrorBoundary (the only public API that changed in sibujs 3.0.0) — every component relies exclusively on signal, derived, effect, tag factories, and cnReactive, all of which are unchanged. No source changes, no behavior changes.
peerDependencies.sibujs relaxed from >=2.2.0 to ^2.0.0. The package only uses the stable public API (signal, derived, effect, tag factories, cnReactive) — none of which has changed since 2.0.0 — so the previous floor unnecessarily excluded consumers on sibujs 2.0.x and 2.1.x. No source changes, no behavior changes. Install compatibility gained, nothing lost.
TooltipContent now accepts a portal?: boolean prop. When set, the content element is teleported to document.body on mount and switched from position: absolute (anchored to the tooltip parent) to position: fixed with coordinates recomputed from the trigger's getBoundingClientRect() every time the tooltip opens. Use this whenever the tooltip's nearest scrolling ancestor has overflow: hidden and would otherwise clip the content. Cleanup is wired through registerDisposer so the teleported node is removed when the owning tooltip is disposed.
SidebarMenuButton previously tried to suppress the icon-mode tooltip when the sidebar was expanded by toggling display on the content element from a one-shot effect. TooltipContent's own display effect re-asserted display = "" on every hover, so the suppression effect was overwritten and the tooltip became visible in expanded mode (and could also leak into the collapsed-without-hover state). The suppression has been replaced with an open() interceptor that wraps the tooltip context so the tooltip simply does not open while the sidebar is expanded or in mobile mode — no more inline-style tug-of-war with TooltipContent.
SidebarContent carries overflow: hidden in group-data-[collapsible=icon] to prevent horizontal scrollbars in icon mode. That clipping also hid the menu-button tooltip, which renders to the right of the (≈48px-wide) icon column. SidebarMenuButton now passes portal: true to its TooltipContent, so the label escapes the sidebar's overflow box and renders correctly next to the icon.
Every component in sibujs-ui has been migrated to the positional shorthand form (tag(children), tag("className", children), tag({ props }, children)). The previous { class, nodes: [...] } object form is gone from the package source — 132 call sites across 34 component files were rewritten in a single AST-driven pass so the internal style is uniform. This is purely an internal refactor: every public component API, prop name, className contract, and DOM output is unchanged, and no runtime behavior is affected.
Why the minor bump: while consumers see no API change, the shorthand is now the canonical authoring style across the SibuJS ecosystem (documented that way in the framework docs and reference site), and aligning the UI package with that canonical form is the kind of cross-cutting source change that deserves a visible version marker rather than a silent patch.
normalizeArgs continues to accept the legacy { nodes: ... } prop form from user code, so existing consumer call sites keep working exactly as before — the change is in how the package's own components are written, not in what shapes they accept.
Previously every stateful component inlined signal(controlled ?? default) to seed its internal state. When the caller passed a reactive getter (() => T), the function itself was stored as the signal value, so every read returned the function rather than the unwrapped value — data-state, aria-*, and change callbacks all broke silently. A new shared bindControlled<T>() helper handles all three shapes (undefined / literal / getter) in one place, and every stateful component has been migrated to use it:
Toggle.pressed— now reactive.Tooltip.open— now acceptsboolean | (() => boolean)(type signature widened + runtime fix).HoverCard.open— same fix as Tooltip.Checkbox.checked,Switch.checked,Tabs.value,RadioGroup.value,Slider.value,Collapsible.open— migrated off the inline pattern ontobindControlled.Select.value,Dialog.open,AlertDialog.open,Accordion.value— the inline fixes shipped earlier have been replaced with the shared helper for consistency.
Many overlay/menu components attached document/window listeners, ResizeObservers, and setTimeout callbacks inside queueMicrotask or effect() without cleanup paths for the unmount-while-open case. Every confirmed leak is now tied to registerDisposer:
- Dialog / AlertDialog / Drawer / Sheet — detaches
documentkeydown listeners, clears pending close timers, and restoresdocument.body.style.overflowif the overlay is disposed while still open. - Popover / DropdownMenu / ContextMenu / Menubar / Select / NavigationMenu / Calendar — detaches
documentmousedown/keydownoutside-click-and-escape listeners on dispose. - Combobox — the outside-click listener was previously leaked on every
effect()re-run becauseeffect()does not honor return-value cleanups. The handler is now hoisted and attach/detach is driven byisOpen()state, with a finalregisterDisposersafety net. - Sidebar —
window.matchMedia('change')andwindow keydown(Ctrl/Cmd+B shortcut) listeners are now detached on dispose. - Accordion — content
ResizeObserver, close-fallbacksetTimeout, and the stateeffect()share a single disposer. - ScrollArea — viewport
ResizeObserveris disconnected on dispose. - Tooltip / HoverCard — pending open/close
setTimeouts and the contenteffect()are cleaned up on dispose.
Label.htmlForalias —LabelandFieldLabelnow accept bothforandhtmlForas the associated-element id. Previously onlyforwas honored;htmlForcall sites silently dropped the prop.normalizeArgsprops-plus-children shorthand —Component({ ...props }, children)(sibujs 1.3.0's canonical form) is now recognised alongside the positionalComponent("className", children)form. The two are disambiguated by the type of the first argument; when a props object is passed, the positional second argument wins over anynodeskey already on the object.
bindControlled<T>()helper — shared controlled-prop utility exported fromsrc/lib/controlled.ts.aria-describedbyfor Tooltip and HoverCard — triggers expose a stablearia-describedbyresolving to the content id so screen readers can associate the content with the focusable trigger.- Keyboard dismissal for Tooltip and HoverCard — pressing
Escapewhile the trigger is focused closes the overlay. - Touch support for Tooltip and HoverCard —
pointerenter/pointerleavereplace the previous mouse-only wiring, so tap devices can surface both overlays. - FOUC prevention for Tooltip and HoverCard — content elements seed
data-state="closed"anddisplay: noneat creation, so closed-state CSS applies on the first paint.
- Sidebar cookie hardening — the sidebar persistence cookie now emits
SameSite=Lax(CSRF hardening) andSecurewhen the page is served over HTTPS. The stored value is non-sensitive (just open/closed), but defense-in-depth.
sibujspeer and dev dependency bumped to^1.3.0— the newbindControlledhelper and disposer-tied resource cleanup depend oncreateId,registerDisposer, and thetag(props, children)shorthand introduced in sibujs 1.3.0. Consumers must upgradesibujsalongsidesibujs-ui1.0.12.
- New test infrastructure —
vitest.config.ts(jsdom environment) plus three test files:tests/smoke.test.ts— 64 tests; every exported component constructs without throwing.tests/bindControlled.test.ts— 5 unit tests for the helper.tests/regressions.test.ts— 23 historical-regression tests covering Checkbox/Switch/Tabs/Toggle reactive controlled props, Label/FieldLabelfor/htmlForcompat, Tooltip/HoverCard reactiveopen+aria-describedby+ Escape + initialdata-state="closed", and Accordion/ScrollAreaResizeObservercleanup on dispose (via a mocked observer).
- 92 tests total, all passing under
jsdom.
- Slider
valueprop is reactive — The implementation now handles the() => number[]getter that the type signature already accepted. Passing a signal getter reactively syncs the slider values viaeffect(), aligning with the pattern used in Select.
- Dialog/AlertDialog
__dialog/__alertDialogavailable duringonElement— The imperative API is now assigned inside an internalonElementcallback before the user's fires, soel.__dialog.open()works immediately withoutsetTimeoutworkarounds. - Dialog/AlertDialog
openprop is reactive —opennow acceptsboolean | (() => boolean). Passing a signal getter reactively syncs the internal state viaeffect(), enabling controlled dialog state without imperative hacks. - Select
valueprop is reactive — The implementation now handles the() => stringgetter that the type signature already accepted. Passing a signal getter reactively syncs the selected value viaeffect(). - DropdownMenuContent no longer clipped by ancestor
overflow— Content is now portaled todocument.bodywithposition: fixedand positioned viagetBoundingClientRect(), avoiding all stacking context and overflow clipping issues.DropdownMenuItemandDropdownMenuRadioItemupdated to resolve the root menu via__dropdownRootwhen inside portaled content.
- Enforce LF line endings — Added
.gitattributeswith* text=auto eol=lfto prevent CRLF formatting drift on Windows.
SelectValuerenders full node tree — Previously, selecting aSelectItemwith complex children (multiple spans, icons, nested markup) flattened everything to.textContent, losing structure and styling.SelectValuenow clones the selected item's DOM subtree viacloneNode(true)and renders it in the trigger, preserving formatting. Falls back to plain text for simple items.
Inputaccepts reactivevalue—InputProps.valuenow acceptsstring | (() => string). The runtime already handled reactive getters viabindAttribute; this fixes the TypeScript type to match.Textareaaccepts reactivevalue— Same fix as Input:TextareaProps.valuenow acceptsstring | (() => string).Badgeaccepts reactivevariant—BadgeProps.variantnow acceptsBadgeVariant | (() => BadgeVariant). When a getter is passed, the CVA class computation is wrapped in a reactive function so the badge re-styles when the variant changes. A newBadgeVarianttype is exported.- Reactive controlled props across all stateful components — The following props now accept
T | (() => T)reactive getters in addition to plain values:Select.value—string | (() => string)Tabs.value—string | (() => string)RadioGroup.value—string | (() => string)Accordion.value—string | string[] | (() => string | string[])Slider.value—number[] | (() => number[])Toggle.pressed—boolean | (() => boolean)Switch.checked—boolean | (() => boolean)Checkbox.checked—boolean | (() => boolean)Collapsible.open—boolean | (() => boolean)