Skip to content

feat(ui): add menubar, navigation-menu, stepper, timeline, tree, resizable, carousel, data-table and select-native - #844

Open
jim-fung wants to merge 13 commits into
cosscom:mainfrom
jim-fung:feat/nine-new-primitives
Open

feat(ui): add menubar, navigation-menu, stepper, timeline, tree, resizable, carousel, data-table and select-native#844
jim-fung wants to merge 13 commits into
cosscom:mainfrom
jim-fung:feat/nine-new-primitives

Conversation

@jim-fung

@jim-fung jim-fung commented Aug 31, 2026

Copy link
Copy Markdown

Summary

Adds nine primitives, closing the component gaps against the Origin UI collection and the roadmap's "expand the primitives set" goal.

  • Menubar + Navigation Menu — wrap the Base UI 1.6 primitives. Menubar is intentionally thin: a styled root and MenubarTrigger; every popup inside the bar is a regular Menu composition. NavigationMenu bundles Portal + Positioner + Popup + the morphing viewport into NavigationMenuPopup with the library's portalProps pattern.
  • Stepper + Timeline — ported from Origin UI and migrated from Radix Slot/asChild to Base UI useRender + mergeProps.
  • Tree — a styling adapter over @headless-tree/core/@headless-tree/react (folder chevron, selection/focus/drag states, TreeDragLine).
  • Resizable — react-resizable-panels v4 API (Group/Panel/Separator, percentage-string sizes).
  • Carousel — Embla, with context-based CarouselApi access and arrow-key support.
  • Data Table — TanStack Table with sortable column headers, client-side pagination, and opt-in row selection wired into the pagination summary.
  • Select Native — a styled, server-renderable native <select> reusing the Select trigger look.

Each ships with a registry entry, two demo particles, a docs page with a full API Reference (part-by-part prop tables) and Examples section — matching the library's documentation standard — plus wiring into meta.json, llms.txt, PAGES_NEW, an agent-skill reference, and a changelog entry. The tree example doubles as a checkbox tree via headless-tree's checkboxesFeature (cascading checked state, indeterminate folders). New registry categories: carousel, data table, menubar, navigation menu, resizable, stepper, timeline, tree.

New dependencies: embla-carousel-react, react-resizable-panels, @headless-tree/core, @headless-tree/react (@tanstack/react-table was already present). They are declared in both apps/ui and packages/ui — the latter matters because the synced copies otherwise resolve the hoisted react-resizable-panels 3.0.6 copy from apps/origin instead of the 4.x API they use.

A deliberate non-addition: no dedicated multiselect primitive — Combobox already supports multiple with chips natively.

Test plan

  • bun run typecheck (including packages/ui), lint, format
  • registry:validate-deps, registry:build, ui:sync
  • docs app production build (81/81 pages)
  • Visual pass on menubar hover/click traversal and the NavigationMenu morphing viewport

…e, carousel, data-table and select-native

Wrap the Base UI 1.6 Menubar and NavigationMenu primitives, port
Stepper, Timeline, Tree (Headless Tree) and Resizable
(react-resizable-panels v3) from Origin UI with useRender instead of
Radix Slot, and add Carousel (Embla), Data Table (TanStack Table) and
a server-renderable Select Native.

Each component ships with a demo particle, docs page, agent skill
reference and registry entry, plus new registry categories. New
dependencies: embla-carousel-react, react-resizable-panels,
@headless-tree/core, @headless-tree/react.
- data-table: wire the pageSize prop into the table's initial pagination
  state (it was accepted but ignored), add opt-in row selection with
  controlled/uncontrolled rowSelection + onRowSelectionChange so the
  pagination selected-count is reachable, and add the missing
  data-slot="data-table" attribute
- stepper: StepperIndicator now honors the render prop through useRender
  instead of only toggling between children and the default content
- tree: drop a no-op Omit that removed a key the props type never had
- carousel particle: define the static slide list outside the component
  per the particle conventions

The reviewed resizable imports are correct as-is: apps/ui resolves
react-resizable-panels 4.12.3 (Group/Panel/Separator); the 3.0.6 copy
at the repo root belongs to apps/origin.
…ages/ui

The resizable, carousel, data-table, and tree components sync into
packages/ui and import react-resizable-panels, embla-carousel-react,
@tanstack/react-table, and @headless-tree. Without declared
dependencies, packages/ui resolved the hoisted react-resizable-panels
3.0.6 copy (apps/origin's) instead of the 4.x API those components
use, breaking packages/ui's typecheck.
@vercel

vercel Bot commented Aug 31, 2026

Copy link
Copy Markdown

@jim-fung is attempting to deploy a commit to the cal Team on Vercel.

A member of the Team first needs to authorize it.

…ives

Bring the nine new component pages up to the library's documentation
standard: an API Reference section with a part-by-part breakdown and
prop tables (matching the scroll-area/tabs/combobox format), and an
Examples section driven by a second demo particle per component.

New example particles: menubar submenu, navigation-menu two-column
panel with active link, vertical stepper with descriptions and a
loading step, horizontal timeline, checkbox tree (cascading checked
state via headless-tree's checkboxesFeature), vertical resizable with
visible handle, carousel with multiple visible slides, data-table row
selection with indeterminate header checkbox, and select-native
multiple + small size.

Writing the select-native example surfaced a typing bug: the variant
size prop intersected with the native select size attribute (number),
so size="sm" was invalid. The component now omits the native key.
ResizablePanel accepts snapPoints (percentages of the group, requires
an id) and snapThreshold (percentage distance, default 5). When a
resize gesture settles — drag release or a keyboard resize — inside a
point's threshold, the panel snaps to it and the largest other panel
absorbs the delta.

Snapping is settle-based rather than applied mid-gesture: driving
resize() during a pointer drag gets overridden by the next pointermove,
which made the snap flicker instead of hold. The zone-entry rule
compares against the previous settled layout, so stepping away from a
snapped point never traps keyboard resizes.

Verified in the browser with synthetic pointer drags: zone-free
targets (40, 58, 44) stay untouched, in-zone targets (27, 48, 72)
settle exactly on 25/50/75, and escaping a zone applies no snap.
…utChanged

The settle-based snapping depended on the Group's onLayoutChanged
callback, which does not fire reliably for real pointer drags (the
library suppresses it while its drag state machine is active and the
final emission path proved inconsistent under trusted input). Real
drags therefore never snapped.

Snapping now runs on the continuous onLayoutChange stream, which is
what powers the live panel readouts and demonstrably fires during real
drags. A pointer gesture tracker (capture-phase pointerdown on a
handle, window pointerup/pointercancel to end the gesture) defers
snapping until the gesture ends so mid-drag snaps cannot fight the
pointer; keyboard and other single-step resizes snap immediately. The
zone-entry rule still guarantees a keyboard nudge off a snapped point
escapes instead of being re-captured.
The zone-entry rule applied to every snap decision, so a pointer
release inside a snap zone only snapped when the previous settled
position was outside it — releasing at 48% snapped when coming from 40%
but not from a snapped 50%, which read as arbitrary.

Pointer releases now snap unconditionally whenever they settle within
snapThreshold of the nearest point (CSS scroll-snap proximity
semantics); keyboard steps keep the zone-entry rule because arrow
steps are smaller than the threshold and unconditional snapping would
trap a nudge trying to leave a point. The snap example also renders
tick marks at the snap points so the zones are visible.
Snapping is now mandatory (CSS scroll-snap mandatory semantics): a
pointer release always parks the panel on the nearest snap point —
there are no free-floating positions in between — instead of only
snapping when released within a threshold (proximity semantics). The
threshold no longer gates pointer snaps; it only sizes the snap zone
for keyboard resizes, which keep the zone-entry rule so arrow-key
steps can pass through a point without being trapped on it.

The edges (0 and 100) are now always snap points, so a panel dragged
to either end of the group settles fully closed or fully open. Edge
snaps also exposed a floating-point bug: the absorbed delta equals
the full remainder of the layout, whose stream sum carries rounding
dust, so a strict negative guard rejected edge snaps at random —
replaced with a small tolerance.

The snap example renders tick marks at every snap point.
- StepperTrigger: default to type="button" so step clicks never submit
  an enclosing form
- p-tree-2: render tree rows as divs (role=treeitem from headless-tree
  still applies) to avoid a checkbox button nested inside the item
  button; stop the checkbox click from also selecting/focusing the row;
  give the checkbox an accessible name
- Data Table: expose sort state via aria-sort on sortable header cells
- registry build: prefer PascalCase component exports in the lazy
  preview resolver so cva variant helpers (selectNativeVariants,
  buttonVariants, ...) are never picked as the component
- docs: correct stale snapping wording — pointer releases snap to the
  nearest point unconditionally, not within the threshold, and never
  during the drag
@jim-fung
jim-fung force-pushed the feat/nine-new-primitives branch from 65e37ca to 0141f46 Compare August 31, 2026 22:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant