ui = single-element primitives: render-capability + composition → components#205
Merged
Conversation
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.
|
📚 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.
…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).
…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.
…rops = Omit<Base> & BadgeVariantProps
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two related passes that make every
uipart a proper single-element primitive.1. Render-capability (Base UI
useRender)60 hand-built
uiparts that rendered a bare<tag {...props}/>(silently droppingrender) now call Base UI'suseRenderdirectly — uniformrenderpolymorphism +classNamemerge + ref forwarding, props fromuseRender.ComponentProps<Tag>. CanonicalmergeProps(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/;uiis exclusively single-element rendering," 5 roots that wrapped their element in aContext.Provider(or a scroll frame) were split:components/ready-made owns the context provider (magnitude/density).Tabs/TabsListsingle-element (no provider, no baked indicator, nocx);componentsowns the provider, the horizontal scroll frame (new single-elementTabsListScrollArea+ reusedui/scroll-areascrollbar/thumb), and the underlineTabsIndicator.Tableis a single<table>; new single-elementTableScrollArea+TableScrollAreaViewportcarry the frame chrome;componentscomposes them with the reusedui/scroll-areascrollbar/thumb/corner and owns the variant provider.uistories wire the context explicitly (story-level composition is fine). The scroll frames reuseui/scroll-areaparts so styling stays inui.Verification
vp checkclean,vp run build(attw + publint) clean, full browser suite 435/435.