Skip to content

ui = single-element primitives: render-capability + composition → components#205

Merged
lifeiscontent merged 82 commits into
mainfrom
feat/render-capable-base
Jun 26, 2026
Merged

ui = single-element primitives: render-capability + composition → components#205
lifeiscontent merged 82 commits into
mainfrom
feat/render-capable-base

Conversation

@lifeiscontent

@lifeiscontent lifeiscontent commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Two related passes that make every ui part a proper single-element primitive.

1. Render-capability (Base UI useRender)

60 hand-built ui parts that rendered a bare <tag {...props}/> (silently dropping render) now call Base UI's useRender directly — uniform render polymorphism + className merge + ref forwarding, props from useRender.ComponentProps<Tag>. Canonical mergeProps(defaultProps, externalProps) order (consumer props win) — also fixed the 3 pre-existing exemplars that had it reversed.

2. Composition → components/ (ui is single-element only)

Per the rule "composition only in components/; ui is exclusively single-element rendering," 5 roots that wrapped their element in a Context.Provider (or a scroll frame) were split:

  • avatar-group, toggle-group, toolbar — ui root is a single element; the components/ ready-made owns the context provider (magnitude/density).
  • tabs — ui Tabs/TabsList single-element (no provider, no baked indicator, no cx); components owns the provider, the horizontal scroll frame (new single-element TabsListScrollArea + reused ui/scroll-area scrollbar/thumb), and the underline TabsIndicator.
  • table — ui Table is a single <table>; new single-element TableScrollArea + TableScrollAreaViewport carry the frame chrome; components composes them with the reused ui/scroll-area scrollbar/thumb/corner and owns the variant provider.

ui stories wire the context explicitly (story-level composition is fine). The scroll frames reuse ui/scroll-area parts so styling stays in ui.

Verification

vp check clean, vp run build (attw + publint) clean, full browser suite 435/435.

The 243 Base-UI-derived parts already supported `render` (prop passthrough), but the
~57 parts we build ourselves (slots, labels, semantic containers) rendered a bare
`<tag {...props}/>` that silently dropped `render` — an observable inconsistency. Convert
them to Base UI's `useRender` directly (no wrapper) so every part has the same element
contract: `render` polymorphism, `className` merging, ref forwarding. Each part's props
now derive from `useRender.ComponentProps<Tag>`.

Also fixes the `mergeProps` argument order in the 3 pre-existing useRender exemplars
(breadcrumb-trigger, nav-item, pagination-per-page-trigger): per the Base UI docs the
order is `mergeProps(defaultProps, externalProps)` so consumer props win — they had it
reversed (defaults won, so a consumer couldn't override aria-*/type/etc.).

Excludes `ui/table/table.tsx` (a true multi-element composition — a `<table>` inside a
scroll frame), not a single styled element.

vp check + build (attw/publint) clean; full suite 435/435.
@github-actions

Copy link
Copy Markdown

📚 Storybook preview: https://pr-205-propel-storybook.vamsi-906.workers.dev

ui AvatarGroup is now a single styled <div> (the overlapping stack); the components
ready-made owns the AvatarGroupContext.Provider that shares magnitude with child Avatars.
Composition lives in components, ui stays single-element.
ui ToggleGroup is now a single BaseToggleGroup (select state + roving focus only); the
components ready-made owns the ToggleGroupContext.Provider that shares magnitude. ui story
wires the context explicitly.
ui Toolbar is a single BaseToolbar.Root (its density still styles the row); the components
ready-made owns the ToolbarDensityContext.Provider that shares density with the controls. ui
story wires the context explicitly.
ui Tabs is a single Tabs.Root and ui TabsList a single Tabs.List (no provider, no baked
indicator, no cx). The components ready-made owns the TabsVariantContext provider, composes
the horizontal scroll frame (new single-element ui TabsListScrollArea + reused ui ScrollArea
scrollbar/thumb), and renders the underline TabsIndicator. ui story wires the context.
…to components

ui Table is now a single render-capable <table>. New single-element ui parts TableScrollArea
(ScrollArea.Root) + TableScrollAreaViewport (ScrollArea.Viewport) carry the frame chrome; the
components ready-made composes them with the reused ui ScrollArea scrollbar/thumb/corner and
owns the TableVariantContext provider. ui story wires the variant context.
Also renamed tableRootVariants->tableScrollAreaVariants, tableViewportVariants->tableScrollAreaViewportVariants.
@lifeiscontent lifeiscontent changed the title Make hand-built ui parts render-capable (Base UI useRender) ui = single-element primitives: render-capability + composition → components Jun 24, 2026
…concern)

ui Avatar is now prop-only (magnitude defaults md, no useContext). AvatarGroupContext lives in
components/avatar; the components Avatar reads it and passes the effective magnitude down, so the
ui primitive doesn't reach into a shared context.
…ncern)

ui Tab/TabsList are now prop-driven (variant required, no useContext); the TabsVariant type stays
in ui (it's a styling type). TabsVariantContext lives in components/tabs; components Tab/TabsList
read it and omit variant from their API + pass it to the ui part. ui story passes variant props.
ui Table is a single <table>; ui TableCell/TableHead are prop-driven (variant required, no
useTableVariant). The context+hook live in components/table; the components cell/head parts
(TableCell, TableHead, TableActionCell, TableEditableCell) read the context and pass variant to
their ui part, omitting it from their own API. TableVariant/TablePinned types stay in ui.
ui Toggle is now prop-driven (magnitude required, no useContext). ToggleGroupContext lives in
components/toggle; a new components Toggle reads it (magnitude override -> group -> md) and passes
it down, so consumers omit magnitude inside a ToggleGroup. ui toggle-group story sizes each Toggle.
ui ToolbarButton/ToolbarToggle/ToolbarMenuTriggerButton are now prop-driven (density required, no
useContext). The context lives in components/toolbar; new components ToolbarButton/ToolbarToggle/
ToolbarMenuTriggerButton read it (density override -> toolbar density) and omit density from their
API. ToolbarDensity/ToolbarElevation types stay in ui; comment pattern uses the components toolbar.
A ui element must not default a variant prop. ui Avatar's magnitude is now required; the
components Avatar keeps it optional and resolves the effective value (group context, else md).
Copilot AI review requested due to automatic review settings June 24, 2026 13:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…ntion)

buttonVariants -> ButtonVariantProps + per-key types (ButtonVariant/Tone/Magnitude/Emphasis/
Stretch) now live in variants.ts; button.tsx imports them for ButtonOwnProps and re-exports them.
Reference for the codebase-wide convention.
…defaulted)

internal/variant-props.ts: StrictVariantProps<cva, DefaultedKeys> makes every variant axis required
(non-null) unless it has a configured default, so an axis with no fallback can't be omitted (no
impossible states). Button uses it: variant/tone/magnitude required; emphasis/stretch optional via
a real defaultVariants ({emphasis:solid, stretch:auto}). ButtonProps = Omit<Base> & ButtonVariantProps
(no hand-built ButtonOwnProps).
Codifies the base/ui/components/internal tiers and the hard rules: single-element ui parts; cva only
in ui named after the part (no Root, no generic names); className/style exposed only at base (Base
UI convention), hidden at ui/components; no cross-component Props/cva/type coupling (share via
internal); StrictVariantProps for variant-prop types (optional iff defaulted; no defaultVariants
today so all required); context + defaults are components concerns.
ui IconButtonRoot -> IconButton; iconButtonRootVariants -> iconButtonVariants as a self-contained
cva (owns its variant/tone/magnitude chrome + geometry; no buttonVariants/ButtonProps import). Types
derive from its own cva via StrictVariantProps. components IconButton aliases the ui part as
IconButtonElement (no Root idiom). Removes the cross-component coupling the protocol forbids.
… fix call sites

ButtonProps = Omit<BaseButton.Props> & ButtonVariantProps (StrictVariantProps; no defaultVariants
today so variant/tone/magnitude/emphasis/stretch are all required). Supplies emphasis/stretch at the
~15 Button call sites (interim solid/auto; sensible defaults to be settled later).
…e it

internal/control-chrome.ts holds the chrome shared by Button (non-link) and IconButton — behavior
base + neutral/danger palette per Type. internal/compose-variants.ts adds composeVariants(shared,
local) = cx(shared(props), local(props)), typed as the intersection of both cvas' props (so a local
cva narrows axes out — IconButton has no link/emphasis/stretch). buttonVariants/iconButtonVariants
are now composeVariants(controlChrome, <local>). No cross-component coupling, no duplicated chrome.
Verified style-stable: the class set for every variant combo is byte-identical to before.
…rol)

Per the protocol: per-key types + <Name>VariantProps via StrictVariantProps live in variants.ts;
Props = Omit<Base.Props> & <Name>VariantProps. avatar-fallback's tone is now required (no JS
default), matching the no-defaults rule — all callers already pass it.
…dialog-popup)

Normalizes the variant-prop typing: banner-body/banner-icon move from raw VariantProps (all
optional) to StrictVariantProps (required, callers already pass variant+tone); dialog-popup moves
from Required<Pick<VariantProps,...>> to StrictVariantProps. Per-key types + <Name>VariantProps in
variants.ts; Props = Omit<Base.Props> & <Name>VariantProps.
…oup)

Required<VariantProps>/local raw aliases -> exported StrictVariantProps in variants.ts; Props =
Omit<Base.Props> & <Name>VariantProps.
Consolidate the prop-naming decisions into CLAUDE.md with the reasoning, so the rhyme/reason is in the
authoritative doc not just memory: the full axis set (tone/prominence/magnitude/sizing/emphasis/
appearance/layout/placement/presentation/mode/surface/density/elevation/orientation) + why each name was
chosen on merit (prominence-not-priority because ghost is a scale floor; tone-not-intent because it spans
decorative hues; emphasis is a degree not a rank; native attr names banned; derive content conditions;
boolean capabilities; split only on different elements). Also fixed rule 9's stale example (prominence,
and the per-axis-vs-cva-props naming distinction).
…se them)

18 ui parts were raw Base-UI re-exports (export const X = BaseY.Portal) or had Props aliased straight
to a Base `.Props` — both inherit BaseUIComponentProps (className/style/render), violating the ui rule.
Wrapped each as a single-element pass-through with Props = Omit<Base.Part.Props, "className"|"style">:
9 dialog/menu/select/etc. portals + Autocomplete/Combobox/Select lists + SelectItemText + the two Select
scroll arrows + NavigationMenu/PreviewCard/Toast portals. Toast.Provider left as-is (context provider,
no element, no className/style). No one was passing className (check stayed green), so behavior is
unchanged — only the leaked API surface is removed.
…spinner

Two fixes: (1) the name was backwards/inconsistent — subtypes take the base as the SUFFIX (Button ->
IconButton), so the anchor-flavored button must be AnchorButton, beside IconButton, not ButtonAnchor.
Renamed the ui + components dirs/files/symbols/cvas (@plane/propel/{ui,components}/anchor-button).
(2) a link DOES have a pending state (a router holds on the link while the next route's data/code loads),
so a spinner is valid — added AnchorButtonSpinner + a loading prop (shows the spinner, sets aria-busy).
The label is NOT dimmed (unlike disabled Button): a pending link isn't disabled, so the label stays full
contrast (axe exempts disabled controls, not aria-busy) and the spinner is the cue.
…E.md 6d)

A part OF a component is prefixed (ButtonIcon, MenuItem, AccordionTrigger); a specialization/KIND of a
component takes the base as a suffix (Button -> IconButton, AnchorButton — like TypeError/ReadStream).
This is why ButtonAnchor was wrong (read as a part of Button) and AnchorButton is right (a kind of
button, beside IconButton). Fixed the stale ButtonAnchor mentions in the doc too.
It's an <a> with link behavior (it navigates) styled to look like a button — so by 6d its base is the
element it IS (Anchor), not its look: a kind of Anchor -> ButtonAnchor (button-look qualifier + Anchor
base), beside the plain Anchor. AnchorButton would be a kind of Button (an action), which this isn't.
Keeps the pending spinner. Updated CLAUDE.md 6d to say: pick the base by element/behavior, never styling.
… a symlink to it

AGENTS.md is the canonical cross-agent instructions filename; CLAUDE.md stays as a symlink so Claude
Code still resolves it. Content unchanged.
…r (<button>+link-look)

I had the two crosses swapped. Correct naming: SUFFIX = the look it presents as, PREFIX = its real
element/trait. So a nav <a> dressed as a button = AnchorButton; a <button> dressed as an inline link
= ButtonAnchor. Renamed the existing <a>-as-button component ButtonAnchor -> AnchorButton, and BUILT
the missing ButtonAnchor (a <button> action wearing the link look). Extracted internal/link-chrome
(the inline-link appearance) shared by Anchor + ButtonAnchor, mirroring control-chrome for the button
look. Fixed AGENTS.md 6c (full 2x2) + 6d (the convention was stated inverted).
Filled the 3 ui folders that lacked a components/<name>: components/anchor + components/button-anchor
(1:1 re-exports of the atomic ui primitives, matching the components/separator pattern) and
components/progress (re-exports the shared ui/progress parts; the ready-made compositions remain
LinearProgress & CircularProgress). Each gets a Components-tier story. Every ui/<name> now has a
matching components/<name>.
Given how many times I flip-flopped: 6d now has the explicit two-step decision (look -> suffix; trait/
real-element -> prefix), the element x look grid (<button>/<a> x button/link -> Button/ButtonAnchor/
AnchorButton/Anchor), and the exact trap (a nav <a> dressed as a button is AnchorButton, not ButtonAnchor
— pick the suffix from the look, not the element, which is what keeps it consistent with IconButton).
…ue ui<->components 1:1

components/{linear,circular}-progress had no ui counterpart, violating the contract (components is the
public API; ui/base are the unwrapped escape hatch — so every ready-made needs matching building
blocks). Split the shared ui/progress parts library into ui/linear-progress (LinearProgress + Track/
Indicator/Value/Label) and ui/circular-progress (CircularProgress + Svg/Track/Indicator), renaming the
parts; dropped ui/progress + components/progress. Rewrote the two ready-mades to compose their own ui
home (and fixed CircularProgress to derive magnitude/tone from the circular types, not the linear ones).
Now 54 ui <-> 54 components, fully bijective.
… across 7 components

The bordered field-control surface (border-sm + bg-layer-2 + subtle->accent focus border/ring) was
re-spelled in 7 cvas under 7 names: inputFieldBox, textAreaFieldBox, selectTrigger, comboboxInputGroup,
autocompleteInputGroup, numberFieldGroup, otpFieldInput. Extracted it to one internal cva with a tone
axis (neutral/danger resting border) and a focus axis (within/visible/self for where the accent ring
keys off, plus none so input/textarea danger keeps its no-ring behavior and otp danger pairs it with a
danger-colored ring). Each control now composes the surface + only its own geometry/interaction delta.
Class sets are byte-identical per control — verified: 55/55 across all 7 families, no visual change.
ui/text-area already owned TextAreaBox, but its variants just delegated to ui/field's
textAreaFieldBoxVariants, and ui/field shipped a duplicate TextAreaFieldBox part — the same box under
two names across two folders. Made TextAreaBox self-contained (composes internal/field-control-surface
like every other control box), pointed the textarea field composition at it, and deleted ui/field's
duplicate TextAreaFieldBox + textAreaFieldBoxVariants. The box now lives once, with its control.
…rol)

For consistency with ui/text-area (which owns TextAreaBox), the input's bordered box + inline icon
slots now live in ui/input as InputBox / InputIconSlot, instead of being field anatomy in ui/field.
ui/field keeps only the field shell (the orientation root, the shared content column, labels, errors,
items, option-magnitude). The shared resting tone is the surface's FieldControlTone. So both bordered
controls now keep their box with the control, and ui/field is purely the generic field shell.
…l composition its own folder)

components/field was a 9-composition grab-bag. Each field sub-type is now its own components folder:
input-field, text-area-field, select-field, autocomplete-field, combobox-field, checkbox-field,
checkbox-group-field, radio-group-field, switch-field (each = Field + the control). components/field
keeps only the generic shell (re-exported Field/FieldLabel/FieldError/etc. + the shared FieldHelperText
/ FieldLabelGroup composition helpers). Each sub-type is a components-only composition (the fields-are-
unique exception: they compose existing Field + control primitives). Consumers repointed; build clean.
… generic shell

Each sub-type folder now has a story (input-field/text-area-field moved; the 7 others written:
select/autocomplete/combobox/checkbox/checkbox-group/radio-group/switch). Components/Field is trimmed
to the generic shell (Field + the control subclasses); the ready-made per-control demos moved to their
own stories. Group-field stories carry the required option children + subcomponents.
… the field folder

Per feedback, the field folders now expose only their public interface — a fully composed field
primitive per control type. Changes:
- Dropped the 4 redundant *FieldControl aliases (Input/TextArea/Radio/Switch re-exports); consumers
  use the real controls. CheckboxFieldControl (the one real control composition) moved to
  internal/ (shared by checkbox-field + checkbox-group-field, not public).
- Moved the InputField orientation root to its own ui/input-field; renamed the shared content column
  InputFieldContent -> FieldControlContent (generic, stays in ui/field).
- components/field exports only Field-prefixed parts + helpers; each components/<x>-field exports only
  its composed <X>Field (+ the group Option). ui/field holds only the generic Field shell.
- Trimmed Components/Field story to the generic shell; repointed all consumers.
…(rule 7)

React context is a composition concern, never ui — ui parts are single elements that take the value as
a prop. ui/field held the FieldOptionMagnitude createContext + Provider + useContext hook (3 files);
consolidated them into internal/field-option-magnitude.tsx (shared by both group fields, alongside the
other shared composition impl like overlay-panel) and repointed the group-field consumers. ui/field is
now context-free.
Renamed 5 root-element cvas to be named after their part, not <part>Root: inputFieldRootVariants ->
inputFieldVariants, meterRootVariants -> meterVariants, otpFieldRootVariants -> otpFieldVariants,
numberFieldRootVariants -> numberFieldVariants, scrollAreaRootVariants -> scrollAreaVariants. Also
fixed tooltip's compose alias (Tooltip as TooltipRootPart -> TooltipElement, per rule 5's 'alias
descriptively, never as XRoot') and its rootProps -> props. Remaining 'Root' references are Base UI's
own API types (MenuRoot.Props, AccordionRoot.Props, SubmenuRoot, etc.), which are correct.
…s (rule 10)

FieldControlContent / InputField (orientation) and FieldDescription / FieldError (magnitude) hand-wrote
their cva axes (inline unions / per-axis types) instead of using the derived <Name>VariantProps. Added
the VariantProps exports to variants.ts and switched each part's Props to Omit<Base.Props, className|
style> & <Name>VariantProps. FieldItemContent forwards magnitude to a child (static cva) so it keeps
the per-axis prop, correctly.
Variant-prop types are used by the part in its Props but must not be re-exported (private). Removed the
export type { ...VariantProps } from ./variants lines from the field parts; the one external consumer
(components FieldLabelGroup) now imports the type from ui/field/variants directly.
…), kept private

Subagent sweep: input, search, checkbox, toggle, slider, number-field, otp-field, pill, meter,
scroll-area, tabs, toolbar — each part's Props now intersects its derived <Name>VariantProps
(StrictVariantProps) instead of hand-writing the cva axis. Renamed colliding local VariantProps configs
to <Name>VariantConfig; renamed the generic tabs rootVariants -> tabsVariants (rule 8). VariantProps
stays private (parts import it for Props but never re-export it). Table left hand-written (its public
'mode' maps to a different cva axis 'surface'). Per-axis types (Magnitude/Tone/...) unchanged.
…axis types are public)

Variant-prop bundle types and cvas are private implementation: a part imports its VariantProps for
its own Props, but nothing re-exports it. Subagent removed the *VariantProps re-exports from 37 ui
parts; hand-fixed the cases its line-based scan missed — button.tsx (multi-line re-export + the
buttonVariants cva), anchor-button, button-anchor, icon-button/index, breadcrumb/index + pagination/
index (cva re-export blocks), and components/button/index (buttonVariants). Per-axis types
(Magnitude/Tone/Prominence/...) remain re-exported for consumers.
…ndicator

CheckboxVisual was a prop-driven copy of the checkbox box (manual data-checked/indeterminate/disabled),
non-standard to Base UI. The menu multi-select row now uses Base UI's own Menu.CheckboxItemIndicator
(keepMounted, styled as the box) so the checked state comes from MenuCheckboxItem context — dropping
the redundant useControllableState. Extracted the shared box look to internal/checkbox-box
(checkboxBoxVariants) since Checkbox and the menu indicator both render it (rule 4); checkboxVariants
delegates to it so the standalone Checkbox is unchanged. Renamed the shared menuItemIndicatorVariants
to menuRadioItemIndicatorVariants (radio-only) and added menuCheckboxItemIndicatorVariants. Deleted
checkbox-visual.tsx + its re-exports; updated the menu story selector.
Update the index.tsx re-export rule to match the private-VariantProps decision: a cva and the
<Name>VariantProps bundle are never re-exported (the part imports VariantProps for its Props but
doesn't re-export it); only the per-axis types (Magnitude/Tone/...) are public.
CheckboxGlyph was a prop-driven icon switcher (indeterminate ? Minus : Check) — consumers threaded
props.indeterminate into it. Base UI's standard: an Indicator mounts when checked/indeterminate and
carries data-checked/data-indeterminate, so the indicator drives the icon. Now two Checkbox.Indicator
parts — CheckboxIndicator (check, hidden when indeterminate) and CheckboxIndeterminateIndicator (dash,
shown only when indeterminate) — each a single styled element that renders its icon child. The
ready-made components provide the lucide Check/Minus. checkbox-field-control now delegates to the
ready-made Checkbox (label-less) so the icons stay a components concern, not internal; Checkbox only
forces its generated id when there's a label to associate.
…ult icon

Enforce: lucide-react only in components source + ui stories, never ui source. The combobox/autocomplete
trigger/clear/item-indicator parts baked {children ?? <Icon/>} (also a rule-1 violation); they're now
pure ui slots that size their icon child via cva, with components/{combobox,autocomplete} ready-mades
supplying the default icon (no className — rule 3). combobox-item-indicator gained a real cva
(comboboxItemIndicatorVariants) instead of a raw class string. Calendar drops its baked lucide Chevron
(ui now uses react-day-picker's default chevron, styled via classNames.chevron); the new
components/calendar ready-made swaps in lucide chevrons, forwarding rdp's className. Repointed the
combobox-field + autocomplete-field consumers to the ready-mades so they keep their default icons.
Add rule 2a: a ui part is icon-agnostic (renders an icon as a {children} slot, sizes it via cva),
never imports lucide-react; the components ready-made supplies the default icon. lucide-react is
allowed only in components source + stories — never ui/base/internal source.
@lifeiscontent lifeiscontent merged commit 2621f05 into main Jun 26, 2026
2 checks passed
@lifeiscontent lifeiscontent deleted the feat/render-capable-base branch June 26, 2026 11:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants