From 48e9eb3d5570a189ed75e564ee9b681e4b1ce1d6 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Wed, 24 Jun 2026 19:06:07 +0700 Subject: [PATCH 01/86] Make hand-built ui parts render-capable via Base UI useRender MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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`. 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 `` inside a scroll frame), not a single styled element. vp check + build (attw/publint) clean; full suite 435/435. --- .../src/ui/avatar-group/avatar-group.tsx | 16 ++++++--------- packages/propel/src/ui/avatar/avatar-icon.tsx | 20 +++++++++---------- packages/propel/src/ui/badge/badge-icon.tsx | 13 ++++++++---- packages/propel/src/ui/badge/badge-label.tsx | 10 ++++++---- packages/propel/src/ui/badge/badge.tsx | 12 +++++++---- .../propel/src/ui/banner/banner-actions.tsx | 10 ++++++---- packages/propel/src/ui/banner/banner-body.tsx | 12 +++++++---- packages/propel/src/ui/banner/banner-icon.tsx | 13 ++++++++---- .../propel/src/ui/banner/banner-title.tsx | 10 ++++++---- packages/propel/src/ui/banner/banner.tsx | 13 ++++++++---- .../src/ui/breadcrumb/breadcrumb-item.tsx | 10 ++++++---- .../src/ui/breadcrumb/breadcrumb-link.tsx | 12 ++++++----- .../src/ui/breadcrumb/breadcrumb-list.tsx | 10 ++++++---- .../src/ui/breadcrumb/breadcrumb-page.tsx | 13 ++++++++---- .../ui/breadcrumb/breadcrumb-separator.tsx | 16 +++++++++------ .../src/ui/breadcrumb/breadcrumb-trigger.tsx | 2 +- .../propel/src/ui/breadcrumb/breadcrumb.tsx | 10 ++++++---- packages/propel/src/ui/button/button-icon.tsx | 13 ++++++++---- .../propel/src/ui/button/button-label.tsx | 10 ++++++---- packages/propel/src/ui/drawer/drawer-body.tsx | 10 ++++++---- .../propel/src/ui/field/input-field-box.tsx | 12 +++++++---- .../src/ui/field/input-field-content.tsx | 15 ++++++++++---- .../src/ui/field/input-field-icon-slot.tsx | 13 ++++++++---- .../src/ui/field/text-area-field-box.tsx | 12 +++++++---- .../propel/src/ui/fieldset/fieldset-body.tsx | 10 ++++++---- packages/propel/src/ui/form/form-actions.tsx | 12 +++++++---- packages/propel/src/ui/form/form-body.tsx | 10 ++++++---- packages/propel/src/ui/menu/menu-footer.tsx | 10 ++++++---- .../propel/src/ui/menu/menu-item-icon.tsx | 13 ++++++++---- .../propel/src/ui/menu/menu-item-meta.tsx | 10 ++++++---- packages/propel/src/ui/menu/menu-search.tsx | 10 ++++++---- packages/propel/src/ui/meter/meter-header.tsx | 10 ++++++---- .../propel/src/ui/nav-item/nav-item-count.tsx | 10 ++++++---- .../src/ui/nav-item/nav-item-header.tsx | 10 ++++++---- .../propel/src/ui/nav-item/nav-item-icon.tsx | 13 ++++++++---- .../propel/src/ui/nav-item/nav-item-label.tsx | 10 ++++++---- packages/propel/src/ui/nav-item/nav-item.tsx | 2 +- .../src/ui/pagination/pagination-item.tsx | 10 ++++++---- .../src/ui/pagination/pagination-list.tsx | 10 ++++++---- .../pagination-per-page-trigger.tsx | 2 +- .../src/ui/pagination/pagination-per-page.tsx | 10 ++++++---- .../src/ui/pagination/pagination-range.tsx | 10 ++++++---- .../propel/src/ui/pagination/pagination.tsx | 10 ++++++---- packages/propel/src/ui/pill/pill-icon.tsx | 13 ++++++++---- packages/propel/src/ui/pill/pill-label.tsx | 10 ++++++---- packages/propel/src/ui/pill/pill-spinner.tsx | 13 ++++++++---- .../propel/src/ui/popover/popover-body.tsx | 10 ++++++---- .../propel/src/ui/popover/popover-intro.tsx | 10 ++++++---- packages/propel/src/ui/search/search-icon.tsx | 13 ++++++++---- packages/propel/src/ui/search/search.tsx | 12 +++++++---- .../propel/src/ui/select/select-field.tsx | 10 ++++++---- .../propel/src/ui/slider/slider-header.tsx | 10 ++++++---- packages/propel/src/ui/table/table-body.tsx | 10 ++++++---- packages/propel/src/ui/table/table-cell.tsx | 14 +++++++------ packages/propel/src/ui/table/table-head.tsx | 19 +++++++++--------- packages/propel/src/ui/table/table-header.tsx | 10 ++++++---- packages/propel/src/ui/table/table-row.tsx | 10 ++++++---- .../propel/src/ui/text-area/text-area-box.tsx | 10 ++++++---- .../propel/src/ui/toast/toast-actions.tsx | 10 ++++++---- packages/propel/src/ui/toggle/toggle-icon.tsx | 13 ++++++++---- 60 files changed, 411 insertions(+), 255 deletions(-) diff --git a/packages/propel/src/ui/avatar-group/avatar-group.tsx b/packages/propel/src/ui/avatar-group/avatar-group.tsx index fee8df0e..982fe651 100644 --- a/packages/propel/src/ui/avatar-group/avatar-group.tsx +++ b/packages/propel/src/ui/avatar-group/avatar-group.tsx @@ -1,4 +1,5 @@ -import type * as React from "react"; +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; import { AvatarGroupContext, type AvatarMagnitude } from "../../ui/avatar"; import { avatarGroupVariants } from "./variants"; @@ -8,7 +9,7 @@ import { avatarGroupVariants } from "./variants"; // standalone Avatar's full scale. export type AvatarGroupMagnitude = Extract; -export type AvatarGroupProps = Omit, "className" | "style"> & { +export type AvatarGroupProps = Omit, "className" | "style"> & { /** Shared size for every avatar in the group; an avatar's own `magnitude` overrides it. */ magnitude: AvatarGroupMagnitude; }; @@ -17,16 +18,11 @@ export type AvatarGroupProps = Omit, "className" | " // `-space-x-1.5` handles the overlap (negative margin between siblings) and each // `Avatar`'s own `border-subtle` is the single ring that separates them, matching // Figma. `magnitude` flows through context so the whole group stays one size. -// None of these reach into the children directly: -// -// -// -// -// -export function AvatarGroup({ magnitude, ...props }: AvatarGroupProps) { +export function AvatarGroup({ magnitude, render, ...props }: AvatarGroupProps) { + const defaultProps: useRender.ElementProps<"div"> = { className: avatarGroupVariants() }; return ( -
+ {useRender({ defaultTagName: "div", render, props: mergeProps(defaultProps, props) })} ); } diff --git a/packages/propel/src/ui/avatar/avatar-icon.tsx b/packages/propel/src/ui/avatar/avatar-icon.tsx index 1bdeeb7a..bdbe57c7 100644 --- a/packages/propel/src/ui/avatar/avatar-icon.tsx +++ b/packages/propel/src/ui/avatar/avatar-icon.tsx @@ -1,13 +1,11 @@ +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; import { type VariantProps } from "class-variance-authority"; -import type * as React from "react"; import { avatarIconVariants } from "./variants"; /** Props for {@link AvatarIcon}, plus the `magnitude` that sizes its glyph. */ -export type AvatarIconProps = Omit< - React.ComponentPropsWithoutRef<"span">, - "className" | "style" -> & { +export type AvatarIconProps = Omit, "className" | "style"> & { /** * Icon size step. Required — the anonymous glyph follows Figma's explicit per-magnitude icon px * values, not a fraction of the avatar, so the size has to be passed in. @@ -21,10 +19,10 @@ export type AvatarIconProps = Omit< * Bakes no glyph — pass the icon as `children`. Decorative (the `Avatar` root carries the * accessible name), so it is `aria-hidden`. */ -export function AvatarIcon({ magnitude, children, ...props }: AvatarIconProps) { - return ( - - {children} - - ); +export function AvatarIcon({ magnitude, render, ...props }: AvatarIconProps) { + const defaultProps: useRender.ElementProps<"span"> = { + "aria-hidden": true, + className: avatarIconVariants({ magnitude }), + }; + return useRender({ defaultTagName: "span", render, props: mergeProps(defaultProps, props) }); } diff --git a/packages/propel/src/ui/badge/badge-icon.tsx b/packages/propel/src/ui/badge/badge-icon.tsx index e122bb22..9a21b88f 100644 --- a/packages/propel/src/ui/badge/badge-icon.tsx +++ b/packages/propel/src/ui/badge/badge-icon.tsx @@ -1,14 +1,19 @@ -import type * as React from "react"; +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; import { badgeIconVariants } from "./variants"; -export type BadgeIconProps = Omit, "className" | "style">; +export type BadgeIconProps = Omit, "className" | "style">; /** * The decorative leading icon at the badge's inline-start (the Figma badge icon). Sizes its single * child to the badge's `--node-size` and inherits the tone's text color, so callers pass a bare * icon. Decorative (the label carries the name), so it is `aria-hidden`. */ -export function BadgeIcon(props: BadgeIconProps) { - return ; +export function BadgeIcon({ render, ...props }: BadgeIconProps) { + const defaultProps: useRender.ElementProps<"span"> = { + "aria-hidden": true, + className: badgeIconVariants(), + }; + return useRender({ defaultTagName: "span", render, props: mergeProps(defaultProps, props) }); } diff --git a/packages/propel/src/ui/badge/badge-label.tsx b/packages/propel/src/ui/badge/badge-label.tsx index 40eb506e..9f9255df 100644 --- a/packages/propel/src/ui/badge/badge-label.tsx +++ b/packages/propel/src/ui/badge/badge-label.tsx @@ -1,13 +1,15 @@ -import type * as React from "react"; +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; import { badgeLabelVariants } from "./variants"; -export type BadgeLabelProps = Omit, "className" | "style">; +export type BadgeLabelProps = Omit, "className" | "style">; /** * The badge's text label. Single-line (the pill clips wrapping); sits between an optional leading * `BadgeIcon` and a trailing `BadgeDismiss`. */ -export function BadgeLabel(props: BadgeLabelProps) { - return ; +export function BadgeLabel({ render, ...props }: BadgeLabelProps) { + const defaultProps: useRender.ElementProps<"span"> = { className: badgeLabelVariants() }; + return useRender({ defaultTagName: "span", render, props: mergeProps(defaultProps, props) }); } diff --git a/packages/propel/src/ui/badge/badge.tsx b/packages/propel/src/ui/badge/badge.tsx index e661d05c..a36776d4 100644 --- a/packages/propel/src/ui/badge/badge.tsx +++ b/packages/propel/src/ui/badge/badge.tsx @@ -1,5 +1,6 @@ +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; import { type VariantProps } from "class-variance-authority"; -import type * as React from "react"; import { badgeVariants } from "./variants"; @@ -7,7 +8,7 @@ export type BadgeMagnitude = NonNullable["mag export type BadgeTone = NonNullable["tone"]>; export type BadgeVariant = NonNullable["variant"]>; -export type BadgeProps = Omit, "className" | "style"> & { +export type BadgeProps = Omit, "className" | "style"> & { /** Color/intent of the badge. */ tone: BadgeTone; /** Size of the badge. */ @@ -21,6 +22,9 @@ export type BadgeProps = Omit, "className" | "style * `BadgeDismiss` inside it (or use the ready-made `components/badge` composition). Sets the tone's * text color and the magnitude's `--node-size`, which its slot children inherit. */ -export function Badge({ tone, magnitude, variant, ...props }: BadgeProps) { - return ; +export function Badge({ tone, magnitude, variant, render, ...props }: BadgeProps) { + const defaultProps: useRender.ElementProps<"span"> = { + className: badgeVariants({ tone, magnitude, variant }), + }; + return useRender({ defaultTagName: "span", render, props: mergeProps(defaultProps, props) }); } diff --git a/packages/propel/src/ui/banner/banner-actions.tsx b/packages/propel/src/ui/banner/banner-actions.tsx index 4267390c..f9f49f08 100644 --- a/packages/propel/src/ui/banner/banner-actions.tsx +++ b/packages/propel/src/ui/banner/banner-actions.tsx @@ -1,10 +1,12 @@ -import type * as React from "react"; +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; import { bannerActionsVariants } from "./variants"; -export type BannerActionsProps = Omit, "className" | "style">; +export type BannerActionsProps = Omit, "className" | "style">; /** The trailing actions group, placed after the message at the banner's inline-end. */ -export function BannerActions(props: BannerActionsProps) { - return
; +export function BannerActions({ render, ...props }: BannerActionsProps) { + const defaultProps: useRender.ElementProps<"div"> = { className: bannerActionsVariants() }; + return useRender({ defaultTagName: "div", render, props: mergeProps(defaultProps, props) }); } diff --git a/packages/propel/src/ui/banner/banner-body.tsx b/packages/propel/src/ui/banner/banner-body.tsx index f57c1dfe..74eef2ff 100644 --- a/packages/propel/src/ui/banner/banner-body.tsx +++ b/packages/propel/src/ui/banner/banner-body.tsx @@ -1,9 +1,10 @@ +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; import { type VariantProps } from "class-variance-authority"; -import type * as React from "react"; import { bannerBodyVariants } from "./variants"; -export type BannerBodyProps = Omit, "className" | "style"> & +export type BannerBodyProps = Omit, "className" | "style"> & VariantProps; /** @@ -11,6 +12,9 @@ export type BannerBodyProps = Omit, "class * `BannerTitle` above a `BannerDescription`. Carries the tone foreground color (inherited by both) * and the per-variant text weight. */ -export function BannerBody({ variant, tone, ...props }: BannerBodyProps) { - return
; +export function BannerBody({ variant, tone, render, ...props }: BannerBodyProps) { + const defaultProps: useRender.ElementProps<"div"> = { + className: bannerBodyVariants({ variant, tone }), + }; + return useRender({ defaultTagName: "div", render, props: mergeProps(defaultProps, props) }); } diff --git a/packages/propel/src/ui/banner/banner-icon.tsx b/packages/propel/src/ui/banner/banner-icon.tsx index 8208b586..90432c5f 100644 --- a/packages/propel/src/ui/banner/banner-icon.tsx +++ b/packages/propel/src/ui/banner/banner-icon.tsx @@ -1,9 +1,10 @@ +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; import { type VariantProps } from "class-variance-authority"; -import type * as React from "react"; import { bannerIconVariants } from "./variants"; -export type BannerIconProps = Omit, "className" | "style"> & +export type BannerIconProps = Omit, "className" | "style"> & VariantProps; /** @@ -11,6 +12,10 @@ export type BannerIconProps = Omit, "clas * child to the banner's node size and tints it per `tone`, so callers pass a bare icon. Decorative * (the message carries the meaning), so it is `aria-hidden`. */ -export function BannerIcon({ variant, tone, ...props }: BannerIconProps) { - return ; +export function BannerIcon({ variant, tone, render, ...props }: BannerIconProps) { + const defaultProps: useRender.ElementProps<"span"> = { + "aria-hidden": true, + className: bannerIconVariants({ variant, tone }), + }; + return useRender({ defaultTagName: "span", render, props: mergeProps(defaultProps, props) }); } diff --git a/packages/propel/src/ui/banner/banner-title.tsx b/packages/propel/src/ui/banner/banner-title.tsx index 50e9a106..87787dd0 100644 --- a/packages/propel/src/ui/banner/banner-title.tsx +++ b/packages/propel/src/ui/banner/banner-title.tsx @@ -1,10 +1,12 @@ -import type * as React from "react"; +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; import { bannerTitleVariants } from "./variants"; -export type BannerTitleProps = Omit, "className" | "style">; +export type BannerTitleProps = Omit, "className" | "style">; /** The banner's headline, stacked above the `BannerDescription` inside the `BannerBody`. */ -export function BannerTitle(props: BannerTitleProps) { - return
; +export function BannerTitle({ render, ...props }: BannerTitleProps) { + const defaultProps: useRender.ElementProps<"div"> = { className: bannerTitleVariants() }; + return useRender({ defaultTagName: "div", render, props: mergeProps(defaultProps, props) }); } diff --git a/packages/propel/src/ui/banner/banner.tsx b/packages/propel/src/ui/banner/banner.tsx index ac3b08a2..3c1a27dc 100644 --- a/packages/propel/src/ui/banner/banner.tsx +++ b/packages/propel/src/ui/banner/banner.tsx @@ -1,5 +1,6 @@ +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; import { type VariantProps } from "class-variance-authority"; -import * as React from "react"; import { bannerVariants } from "./variants"; @@ -16,7 +17,7 @@ const toneRole: Record = { danger: "alert", }; -export type BannerProps = Omit, "className" | "style"> & { +export type BannerProps = Omit, "className" | "style"> & { /** Figma Scope: a full-width page strip (`page`) or a self-contained card (`inline`). */ variant: BannerVariant; /** Figma Intent: the banner's meaning/color. */ @@ -28,6 +29,10 @@ export type BannerProps = Omit, "className" | "style * `BannerDismiss`) in a row. The `role`/`aria-live` come from the tone so assistive tech announces * problems assertively and advisories politely; consumers can override via spread. */ -export function Banner({ variant, tone, ...props }: BannerProps) { - return
; +export function Banner({ variant, tone, render, ...props }: BannerProps) { + const defaultProps: useRender.ElementProps<"div"> = { + role: toneRole[tone], + className: bannerVariants({ variant, tone }), + }; + return useRender({ defaultTagName: "div", render, props: mergeProps(defaultProps, props) }); } diff --git a/packages/propel/src/ui/breadcrumb/breadcrumb-item.tsx b/packages/propel/src/ui/breadcrumb/breadcrumb-item.tsx index ed19bed3..6e25838b 100644 --- a/packages/propel/src/ui/breadcrumb/breadcrumb-item.tsx +++ b/packages/propel/src/ui/breadcrumb/breadcrumb-item.tsx @@ -1,10 +1,12 @@ -import type * as React from "react"; +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; import { breadcrumbItemVariants } from "./variants"; -export type BreadcrumbItemProps = Omit, "className" | "style">; +export type BreadcrumbItemProps = Omit, "className" | "style">; /** One step in the trail: a list item holding a link, page, or menu crumb. */ -export function BreadcrumbItem(props: BreadcrumbItemProps) { - return
  • ; +export function BreadcrumbItem({ render, ...props }: BreadcrumbItemProps) { + const defaultProps: useRender.ElementProps<"li"> = { className: breadcrumbItemVariants() }; + return useRender({ defaultTagName: "li", render, props: mergeProps(defaultProps, props) }); } diff --git a/packages/propel/src/ui/breadcrumb/breadcrumb-link.tsx b/packages/propel/src/ui/breadcrumb/breadcrumb-link.tsx index 4318291e..e9d3ab85 100644 --- a/packages/propel/src/ui/breadcrumb/breadcrumb-link.tsx +++ b/packages/propel/src/ui/breadcrumb/breadcrumb-link.tsx @@ -1,10 +1,12 @@ -import type * as React from "react"; +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; import { breadcrumbLinkVariants } from "./variants"; -export type BreadcrumbLinkProps = Omit, "className" | "style">; +export type BreadcrumbLinkProps = Omit, "className" | "style">; -/** A navigable crumb — renders an anchor styled as a hoverable pill. */ -export function BreadcrumbLink(props: BreadcrumbLinkProps) { - return ; +/** A navigable crumb — an anchor styled as a hoverable pill. Pass `render` to use a router link. */ +export function BreadcrumbLink({ render, ...props }: BreadcrumbLinkProps) { + const defaultProps: useRender.ElementProps<"a"> = { className: breadcrumbLinkVariants() }; + return useRender({ defaultTagName: "a", render, props: mergeProps(defaultProps, props) }); } diff --git a/packages/propel/src/ui/breadcrumb/breadcrumb-list.tsx b/packages/propel/src/ui/breadcrumb/breadcrumb-list.tsx index 406f9139..56027888 100644 --- a/packages/propel/src/ui/breadcrumb/breadcrumb-list.tsx +++ b/packages/propel/src/ui/breadcrumb/breadcrumb-list.tsx @@ -1,10 +1,12 @@ -import type * as React from "react"; +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; import { breadcrumbListVariants } from "./variants"; -export type BreadcrumbListProps = Omit, "className" | "style">; +export type BreadcrumbListProps = Omit, "className" | "style">; /** The ordered list of crumbs inside a `Breadcrumb` landmark. */ -export function BreadcrumbList(props: BreadcrumbListProps) { - return
      ; +export function BreadcrumbList({ render, ...props }: BreadcrumbListProps) { + const defaultProps: useRender.ElementProps<"ol"> = { className: breadcrumbListVariants() }; + return useRender({ defaultTagName: "ol", render, props: mergeProps(defaultProps, props) }); } diff --git a/packages/propel/src/ui/breadcrumb/breadcrumb-page.tsx b/packages/propel/src/ui/breadcrumb/breadcrumb-page.tsx index f8d5b41d..242c637a 100644 --- a/packages/propel/src/ui/breadcrumb/breadcrumb-page.tsx +++ b/packages/propel/src/ui/breadcrumb/breadcrumb-page.tsx @@ -1,10 +1,15 @@ -import type * as React from "react"; +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; import { breadcrumbPageVariants } from "./variants"; -export type BreadcrumbPageProps = Omit, "className" | "style">; +export type BreadcrumbPageProps = Omit, "className" | "style">; /** The current page — the last, non-navigable crumb. */ -export function BreadcrumbPage(props: BreadcrumbPageProps) { - return ; +export function BreadcrumbPage({ render, ...props }: BreadcrumbPageProps) { + const defaultProps: useRender.ElementProps<"span"> = { + "aria-current": "page", + className: breadcrumbPageVariants(), + }; + return useRender({ defaultTagName: "span", render, props: mergeProps(defaultProps, props) }); } diff --git a/packages/propel/src/ui/breadcrumb/breadcrumb-separator.tsx b/packages/propel/src/ui/breadcrumb/breadcrumb-separator.tsx index a5bf99dc..e0c415cb 100644 --- a/packages/propel/src/ui/breadcrumb/breadcrumb-separator.tsx +++ b/packages/propel/src/ui/breadcrumb/breadcrumb-separator.tsx @@ -1,15 +1,19 @@ -import type * as React from "react"; +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; import { breadcrumbSeparatorVariants } from "./variants"; -export type BreadcrumbSeparatorProps = Omit, "className" | "style">; +export type BreadcrumbSeparatorProps = Omit, "className" | "style">; /** * The visual divider between crumbs. A node-slot: it sizes its single child (icon or character), so * callers pass the divider glyph as `children`. Decorative, so it is removed from the a11y tree. */ -export function BreadcrumbSeparator(props: BreadcrumbSeparatorProps) { - return ( -
    1. - ); +export function BreadcrumbSeparator({ render, ...props }: BreadcrumbSeparatorProps) { + const defaultProps: useRender.ElementProps<"li"> = { + "aria-hidden": true, + role: "presentation", + className: breadcrumbSeparatorVariants(), + }; + return useRender({ defaultTagName: "li", render, props: mergeProps(defaultProps, props) }); } diff --git a/packages/propel/src/ui/breadcrumb/breadcrumb-trigger.tsx b/packages/propel/src/ui/breadcrumb/breadcrumb-trigger.tsx index c50e05db..61360ec1 100644 --- a/packages/propel/src/ui/breadcrumb/breadcrumb-trigger.tsx +++ b/packages/propel/src/ui/breadcrumb/breadcrumb-trigger.tsx @@ -24,6 +24,6 @@ export function BreadcrumbTrigger({ group = false, render, ...props }: Breadcrum return useRender({ defaultTagName: "button", render, - props: mergeProps(props, defaultProps), + props: mergeProps(defaultProps, props), }); } diff --git a/packages/propel/src/ui/breadcrumb/breadcrumb.tsx b/packages/propel/src/ui/breadcrumb/breadcrumb.tsx index 5c9712ee..e226a929 100644 --- a/packages/propel/src/ui/breadcrumb/breadcrumb.tsx +++ b/packages/propel/src/ui/breadcrumb/breadcrumb.tsx @@ -1,8 +1,10 @@ -import type * as React from "react"; +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; -export type BreadcrumbProps = Omit, "className" | "style">; +export type BreadcrumbProps = Omit, "className" | "style">; /** Breadcrumb trail landmark: a `
  • `). Holds the data `TableRow`s. */ -export function TableBody(props: TableBodyProps) { - return ; +export function TableBody({ render, ...props }: TableBodyProps) { + const defaultProps: useRender.ElementProps<"tbody"> = { className: tableBodyVariants() }; + return useRender({ defaultTagName: "tbody", render, props: mergeProps(defaultProps, props) }); } diff --git a/packages/propel/src/ui/table/table-cell.tsx b/packages/propel/src/ui/table/table-cell.tsx index 6e7795f9..1f64e58e 100644 --- a/packages/propel/src/ui/table/table-cell.tsx +++ b/packages/propel/src/ui/table/table-cell.tsx @@ -1,11 +1,12 @@ -import type * as React from "react"; +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; import { TablePinned, useTableVariant } from "./table-context"; import { tableCellVariants } from "./variants"; export type TableCellPadding = "cell" | "trigger"; -export type TableCellProps = Omit, "className" | "style"> & { +export type TableCellProps = Omit, "className" | "style"> & { /** Pin this cell to the inline-start/end edge when the table scrolls sideways. */ pinned?: TablePinned; /** @@ -16,9 +17,10 @@ export type TableCellProps = Omit, "className" | "sty }; /** A data cell (``). Holds a single `TableRow` of `TableHead` cells. */ -export function TableHeader(props: TableHeaderProps) { - return ; +export function TableHeader({ render, ...props }: TableHeaderProps) { + const defaultProps: useRender.ElementProps<"thead"> = { className: tableHeaderVariants() }; + return useRender({ defaultTagName: "thead", render, props: mergeProps(defaultProps, props) }); } diff --git a/packages/propel/src/ui/table/table-row.tsx b/packages/propel/src/ui/table/table-row.tsx index 276b3d6f..249d67ed 100644 --- a/packages/propel/src/ui/table/table-row.tsx +++ b/packages/propel/src/ui/table/table-row.tsx @@ -1,10 +1,12 @@ -import type * as React from "react"; +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; import { tableRowVariants } from "./variants"; -export type TableRowProps = Omit, "className" | "style">; +export type TableRowProps = Omit, "className" | "style">; /** A table row (``). */ -export function TableRow(props: TableRowProps) { - return ; +export function TableRow({ render, ...props }: TableRowProps) { + const defaultProps: useRender.ElementProps<"tr"> = { className: tableRowVariants() }; + return useRender({ defaultTagName: "tr", render, props: mergeProps(defaultProps, props) }); } diff --git a/packages/propel/src/ui/text-area/text-area-box.tsx b/packages/propel/src/ui/text-area/text-area-box.tsx index 1b7da4fd..7c9f22fc 100644 --- a/packages/propel/src/ui/text-area/text-area-box.tsx +++ b/packages/propel/src/ui/text-area/text-area-box.tsx @@ -1,8 +1,9 @@ -import type * as React from "react"; +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; import { textAreaBoxVariants, type TextAreaTone } from "./variants"; -export type TextAreaBoxProps = Omit, "className" | "style"> & { +export type TextAreaBoxProps = Omit, "className" | "style"> & { /** Resting treatment. `neutral` | `danger` (the Figma "error" state). */ tone: TextAreaTone; }; @@ -12,6 +13,7 @@ export type TextAreaBoxProps = Omit, "className" | " * focus/error border treatments so a standalone textarea has the same chrome as one inside a * `Field`. Place a single `TextArea` (and any inline affordances) as its children. */ -export function TextAreaBox({ tone, ...props }: TextAreaBoxProps) { - return
    ; +export function TextAreaBox({ tone, render, ...props }: TextAreaBoxProps) { + const defaultProps: useRender.ElementProps<"div"> = { className: textAreaBoxVariants({ tone }) }; + return useRender({ defaultTagName: "div", render, props: mergeProps(defaultProps, props) }); } diff --git a/packages/propel/src/ui/toast/toast-actions.tsx b/packages/propel/src/ui/toast/toast-actions.tsx index f1987bcd..8d3b4f6f 100644 --- a/packages/propel/src/ui/toast/toast-actions.tsx +++ b/packages/propel/src/ui/toast/toast-actions.tsx @@ -1,13 +1,15 @@ -import type * as React from "react"; +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; import { toastActionsVariants } from "./variants"; -export type ToastActionsProps = Omit, "className" | "style">; +export type ToastActionsProps = Omit, "className" | "style">; /** * The full-width action row beneath a toast's text and optional progress bar. Holds the * inline-start `ToastActionGroup` cluster and an optional inline-end `ToastAction`. */ -export function ToastActions(props: ToastActionsProps) { - return
    ; +export function ToastActions({ render, ...props }: ToastActionsProps) { + const defaultProps: useRender.ElementProps<"div"> = { className: toastActionsVariants() }; + return useRender({ defaultTagName: "div", render, props: mergeProps(defaultProps, props) }); } diff --git a/packages/propel/src/ui/toggle/toggle-icon.tsx b/packages/propel/src/ui/toggle/toggle-icon.tsx index b2015766..93be7bfe 100644 --- a/packages/propel/src/ui/toggle/toggle-icon.tsx +++ b/packages/propel/src/ui/toggle/toggle-icon.tsx @@ -1,14 +1,19 @@ -import type * as React from "react"; +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; import { toggleIconVariants } from "./variants"; -export type ToggleIconProps = Omit, "className" | "style">; +export type ToggleIconProps = Omit, "className" | "style">; /** * The icon slot inside a `Toggle`. Sizes its single child to the toggle's `--node-size` (set by the * toggle's `magnitude`), so callers pass a bare icon with no sizing class. Decorative (the `Toggle` * carries the accessible name via `aria-label`), so it is `aria-hidden`. */ -export function ToggleIcon(props: ToggleIconProps) { - return ; +export function ToggleIcon({ render, ...props }: ToggleIconProps) { + const defaultProps: useRender.ElementProps<"span"> = { + "aria-hidden": true, + className: toggleIconVariants(), + }; + return useRender({ defaultTagName: "span", render, props: mergeProps(defaultProps, props) }); } From b83b9c3f5b3cb599198a3613cdb0631b347a15e2 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Wed, 24 Jun 2026 19:28:52 +0700 Subject: [PATCH 02/86] avatar-group: move the magnitude context provider to the components tier ui AvatarGroup is now a single styled
    (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. --- .../components/avatar-group/avatar-group.tsx | 30 ++++++++++++ .../src/components/avatar-group/index.tsx | 4 +- .../ui/avatar-group/avatar-group.stories.tsx | 48 ++++++++----------- .../src/ui/avatar-group/avatar-group.tsx | 28 ++++------- 4 files changed, 60 insertions(+), 50 deletions(-) create mode 100644 packages/propel/src/components/avatar-group/avatar-group.tsx diff --git a/packages/propel/src/components/avatar-group/avatar-group.tsx b/packages/propel/src/components/avatar-group/avatar-group.tsx new file mode 100644 index 00000000..62b135a3 --- /dev/null +++ b/packages/propel/src/components/avatar-group/avatar-group.tsx @@ -0,0 +1,30 @@ +import type * as React from "react"; + +import { AvatarGroupContext, type AvatarMagnitude } from "../../ui/avatar"; +import { + AvatarGroup as AvatarGroupRoot, + type AvatarGroupProps as AvatarGroupRootProps, +} from "../../ui/avatar-group"; + +// Figma's "Avatar Groups" component only defines three sizes (Small/Base/Large = 16/20/24px), so +// groups are limited to the matching magnitudes — narrower than a standalone Avatar's full scale. +export type AvatarGroupMagnitude = Extract; + +export type AvatarGroupProps = AvatarGroupRootProps & { + /** Shared size for every avatar in the group; an avatar's own `magnitude` overrides it. */ + magnitude: AvatarGroupMagnitude; + children?: React.ReactNode; +}; + +/** + * The ready-made overlapping avatar stack: shares `magnitude` with every `Avatar` inside via + * context (an avatar's own `magnitude` still wins), composed around the styled `ui/avatar-group` + * container. Each `Avatar`'s own `border-subtle` is the single ring that separates them. + */ +export function AvatarGroup({ magnitude, children, ...props }: AvatarGroupProps) { + return ( + + {children} + + ); +} diff --git a/packages/propel/src/components/avatar-group/index.tsx b/packages/propel/src/components/avatar-group/index.tsx index 15a57490..ae76d7d7 100644 --- a/packages/propel/src/components/avatar-group/index.tsx +++ b/packages/propel/src/components/avatar-group/index.tsx @@ -1,3 +1 @@ -// Ready-made 1:1 re-export of the ui primitive. Drop down to `@plane/propel/ui/avatar-group` only -// when you need the lower-level parts. -export * from "../../ui/avatar-group"; +export { AvatarGroup, type AvatarGroupProps, type AvatarGroupMagnitude } from "./avatar-group"; diff --git a/packages/propel/src/ui/avatar-group/avatar-group.stories.tsx b/packages/propel/src/ui/avatar-group/avatar-group.stories.tsx index fba57c33..0540886d 100644 --- a/packages/propel/src/ui/avatar-group/avatar-group.stories.tsx +++ b/packages/propel/src/ui/avatar-group/avatar-group.stories.tsx @@ -4,28 +4,27 @@ import { expect } from "storybook/test"; import { Avatar, AvatarFallback, AvatarImage } from "../avatar/index"; import { AvatarGroup } from "./index"; -// UI-tier story: composes the ATOMIC avatar parts (`Avatar` root + `AvatarImage` + -// `AvatarFallback`). The components-tier `AvatarGroup` story uses the ready-made -// `Avatar` (image → initials → person icon) instead. +// UI-tier story: the `AvatarGroup` ui primitive is just the styled overlapping-stack container (a +// single `
    `); each `Avatar` inside sets its own size. The components-tier `AvatarGroup` adds +// the shared-`magnitude` context so the whole group sizes at once. const meta = { title: "UI/AvatarGroup", component: AvatarGroup, subcomponents: { Avatar, AvatarImage, AvatarFallback }, - args: { magnitude: "sm" }, } satisfies Meta; export default meta; type Story = StoryObj; -/** `magnitude` on the group sizes every avatar at once — the children don't set it. */ +/** The overlapping stack; each avatar sets its own `magnitude`. */ export const TwoMembers: Story = { - render: (args) => ( - - + render: () => ( + + AL - + GH @@ -35,51 +34,46 @@ export const TwoMembers: Story = { /** Three avatars, the last one initials-only (no image). */ export const ThreeMembers: Story = { - render: (args) => ( - - + render: () => ( + + AL - + GH - + LT ), play: async ({ canvas }) => { - // Each avatar exposes role="img"; querying by role proves all three rendered. const avatars = canvas.getAllByRole("img"); await expect(avatars).toHaveLength(3); - // The group's `magnitude="sm"` flows to every avatar (sm = 24px) even though - // none set it themselves. + // Each avatar is `sm` (24px). await expect(avatars[0]).toHaveStyle({ width: "24px" }); }, }; -/** - * Overflow pattern: a few member images, then a final avatar whose fallback shows a "+N" count for - * the rest. - */ +/** Overflow pattern: a few member images, then a final avatar whose fallback shows a "+N" count. */ export const OverflowCount: Story = { - render: (args) => ( - - + render: () => ( + + AL - + GH - + LT - + +4 diff --git a/packages/propel/src/ui/avatar-group/avatar-group.tsx b/packages/propel/src/ui/avatar-group/avatar-group.tsx index 982fe651..7a0eb3e7 100644 --- a/packages/propel/src/ui/avatar-group/avatar-group.tsx +++ b/packages/propel/src/ui/avatar-group/avatar-group.tsx @@ -1,28 +1,16 @@ import { mergeProps } from "@base-ui/react/merge-props"; import { useRender } from "@base-ui/react/use-render"; -import { AvatarGroupContext, type AvatarMagnitude } from "../../ui/avatar"; import { avatarGroupVariants } from "./variants"; -// Figma's "Avatar Groups" component only defines three sizes (Small/Base/Large = -// 16/20/24px), so groups are limited to the matching magnitudes — narrower than a -// standalone Avatar's full scale. -export type AvatarGroupMagnitude = Extract; +export type AvatarGroupProps = Omit, "className" | "style">; -export type AvatarGroupProps = Omit, "className" | "style"> & { - /** Shared size for every avatar in the group; an avatar's own `magnitude` overrides it. */ - magnitude: AvatarGroupMagnitude; -}; - -// Overlapping stack of avatars — mirrors the Figma "Avatar groups" -6px overlap. -// `-space-x-1.5` handles the overlap (negative margin between siblings) and each -// `Avatar`'s own `border-subtle` is the single ring that separates them, matching -// Figma. `magnitude` flows through context so the whole group stays one size. -export function AvatarGroup({ magnitude, render, ...props }: AvatarGroupProps) { +/** + * The overlapping avatar stack — the styled container only (a single `
    ` with the Figma -6px + * overlap). Compose `Avatar`s inside it. The shared-`magnitude` behavior is the ready-made + * `components/avatar-group`, which wraps this in the `AvatarGroupContext` provider. + */ +export function AvatarGroup({ render, ...props }: AvatarGroupProps) { const defaultProps: useRender.ElementProps<"div"> = { className: avatarGroupVariants() }; - return ( - - {useRender({ defaultTagName: "div", render, props: mergeProps(defaultProps, props) })} - - ); + return useRender({ defaultTagName: "div", render, props: mergeProps(defaultProps, props) }); } From 5490efa95deb715c60d29f79f16b434894781d22 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Wed, 24 Jun 2026 19:31:19 +0700 Subject: [PATCH 03/86] toggle-group: move the magnitude context provider to the components tier 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. --- .../src/components/toggle-group/index.tsx | 4 +- .../components/toggle-group/toggle-group.tsx | 31 +++++++ .../ui/toggle-group/toggle-group.stories.tsx | 81 ++++++++++--------- .../src/ui/toggle-group/toggle-group.tsx | 24 ++---- 4 files changed, 79 insertions(+), 61 deletions(-) create mode 100644 packages/propel/src/components/toggle-group/toggle-group.tsx diff --git a/packages/propel/src/components/toggle-group/index.tsx b/packages/propel/src/components/toggle-group/index.tsx index 59397e8a..78f3c8e7 100644 --- a/packages/propel/src/components/toggle-group/index.tsx +++ b/packages/propel/src/components/toggle-group/index.tsx @@ -1,3 +1 @@ -// Ready-made 1:1 re-export of the ui primitive. Drop down to `@plane/propel/ui/toggle-group` only -// when you need the lower-level parts. -export * from "../../ui/toggle-group"; +export { ToggleGroup, type ToggleGroupProps } from "./toggle-group"; diff --git a/packages/propel/src/components/toggle-group/toggle-group.tsx b/packages/propel/src/components/toggle-group/toggle-group.tsx new file mode 100644 index 00000000..a2cbe575 --- /dev/null +++ b/packages/propel/src/components/toggle-group/toggle-group.tsx @@ -0,0 +1,31 @@ +import type * as React from "react"; + +import { ToggleGroupContext } from "../../ui/toggle/toggle-group-context"; +import type { ToggleMagnitude } from "../../ui/toggle/variants"; +import { + ToggleGroup as ToggleGroupRoot, + type ToggleGroupProps as ToggleGroupRootProps, +} from "../../ui/toggle-group"; + +export type ToggleGroupProps = ToggleGroupRootProps & { + /** Size applied to every `Toggle` in the group (each `Toggle` can still override it). */ + magnitude: ToggleMagnitude; + children?: React.ReactNode; +}; + +/** + * The ready-made toggle group: shares `magnitude` with every `Toggle` inside via context (a + * `Toggle`'s own `magnitude` still wins), composed around the `ui/toggle-group` primitive (which + * manages single/multi-select state + roving focus). + */ +export function ToggleGroup({ + magnitude, + children, + ...props +}: ToggleGroupProps) { + return ( + + {children} + + ); +} diff --git a/packages/propel/src/ui/toggle-group/toggle-group.stories.tsx b/packages/propel/src/ui/toggle-group/toggle-group.stories.tsx index 0e13c072..a151f869 100644 --- a/packages/propel/src/ui/toggle-group/toggle-group.stories.tsx +++ b/packages/propel/src/ui/toggle-group/toggle-group.stories.tsx @@ -3,24 +3,21 @@ import { AlignCenter, AlignLeft, AlignRight } from "lucide-react"; import { expect, fn } from "storybook/test"; import { Toggle, ToggleIcon } from "../toggle/index"; +import { ToggleGroupContext } from "../toggle/toggle-group-context"; import { ToggleGroup } from "./index"; -// UI-tier story: composes the atomic `ToggleGroup` with atomic `Toggle` items. The group -// manages single/multi-select state + roving focus and sizes every toggle via its -// `magnitude` (each `Toggle` inherits it through context). The components-tier story uses -// the ready-made re-exports. +// UI-tier story: the atomic `ToggleGroup` (single/multi-select state + roving focus) composed with +// atomic `Toggle` items. `ToggleGroup` is a single element; the shared-`magnitude` context is wired +// here explicitly (the components-tier `ToggleGroup` does this via its provider) so every `Toggle` +// inherits one size. const meta = { title: "UI/ToggleGroup", component: ToggleGroup, subcomponents: { Toggle, ToggleIcon }, - args: { magnitude: "md" }, parameters: { a11y: { // Base UI's ToggleGroup renders `role="group"` with `aria-orientation`, which axe's - // aria-allowed-attr flags because `aria-orientation` isn't in the allowed attribute - // set for `role="group"`. This is Base UI's intended roving-focus markup, not invalid - // intent, so suppress just this rule (mirrors the menu.stories aria-required-children - // precedent). + // aria-allowed-attr flags. This is Base UI's intended roving-focus markup, so suppress it. config: { rules: [{ id: "aria-allowed-attr", enabled: false }] }, }, }, @@ -31,25 +28,27 @@ type Story = StoryObj; /** A single-select group of three alignment toggles. */ export const Default: Story = { - args: { magnitude: "md", defaultValue: ["left"], onValueChange: fn() }, + args: { defaultValue: ["left"], onValueChange: fn() }, render: (args) => ( - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + ), play: async ({ canvas, userEvent, args }) => { const left = canvas.getByRole("button", { name: "Align left" }); @@ -66,20 +65,22 @@ export const Default: Story = { /** `multiple` lets more than one toggle stay pressed. */ export const Multiple: Story = { tags: ["!dev", "!autodocs", "!manifest"], - args: { magnitude: "md", multiple: true, defaultValue: [] }, + args: { multiple: true, defaultValue: [] }, render: (args) => ( - - - - - - - - - - - - + + + + + + + + + + + + + + ), play: async ({ canvas, userEvent }) => { const bold = canvas.getByRole("button", { name: "Bold" }); diff --git a/packages/propel/src/ui/toggle-group/toggle-group.tsx b/packages/propel/src/ui/toggle-group/toggle-group.tsx index c9ee30f1..cddc6d7d 100644 --- a/packages/propel/src/ui/toggle-group/toggle-group.tsx +++ b/packages/propel/src/ui/toggle-group/toggle-group.tsx @@ -1,30 +1,18 @@ import { ToggleGroup as BaseToggleGroup } from "@base-ui/react/toggle-group"; -import { ToggleGroupContext } from "../toggle/toggle-group-context"; -import type { ToggleMagnitude } from "../toggle/variants"; import { toggleGroupVariants } from "./variants"; export type ToggleGroupProps = Omit< BaseToggleGroup.Props, "className" | "style" -> & { - /** Size applied to every `Toggle` in the group (each `Toggle` can still override it). */ - magnitude: ToggleMagnitude; -}; +>; /** * Groups related `Toggle`s, managing single- or multi-select state and roving focus. Pass `value` + - * `onValueChange` (array; `toggleMultiple` to allow more than one pressed). `magnitude` sizes every - * toggle inside via context. Maps 1:1 to Base UI's `ToggleGroup`. + * `onValueChange` (array; `multiple` to allow more than one pressed). Maps 1:1 to Base UI's + * `ToggleGroup`. The shared-`magnitude` behavior is the ready-made `components/toggle-group`, which + * wraps this in the `ToggleGroupContext` provider. */ -export function ToggleGroup({ - magnitude, - children, - ...props -}: ToggleGroupProps) { - return ( - - {children} - - ); +export function ToggleGroup(props: ToggleGroupProps) { + return ; } From d72102941c6479b8bd45cfb5b990d1d04ffdac6b Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Wed, 24 Jun 2026 19:47:22 +0700 Subject: [PATCH 04/86] toolbar: move the density context provider to the components tier 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. --- .../propel/src/components/toolbar/index.tsx | 3 +- .../propel/src/components/toolbar/toolbar.tsx | 22 ++ .../propel/src/ui/toolbar/toolbar.stories.tsx | 200 +++++++++--------- packages/propel/src/ui/toolbar/toolbar.tsx | 18 +- 4 files changed, 133 insertions(+), 110 deletions(-) create mode 100644 packages/propel/src/components/toolbar/toolbar.tsx diff --git a/packages/propel/src/components/toolbar/index.tsx b/packages/propel/src/components/toolbar/index.tsx index c9cd33bb..50794dd0 100644 --- a/packages/propel/src/components/toolbar/index.tsx +++ b/packages/propel/src/components/toolbar/index.tsx @@ -1,5 +1,4 @@ export { - Toolbar, ToolbarButton, ToolbarMenuTriggerButton, ToolbarMenuTriggerIndicator, @@ -17,11 +16,11 @@ export { type ToolbarElevation, type ToolbarGroupProps, type ToolbarItemIconProps, - type ToolbarProps, type ToolbarSeparatorProps, type ToolbarToggleGroupProps, type ToolbarToggleProps, } from "../../ui/toolbar/index"; +export { Toolbar, type ToolbarProps } from "./toolbar"; export { ToolbarMenu, type ToolbarMenuProps } from "./toolbar-menu"; export { ToolbarMenuContent, type ToolbarMenuContentProps } from "./toolbar-menu-content"; export { ToolbarMenuItem, type ToolbarMenuItemProps } from "./toolbar-menu-item"; diff --git a/packages/propel/src/components/toolbar/toolbar.tsx b/packages/propel/src/components/toolbar/toolbar.tsx new file mode 100644 index 00000000..20ed51db --- /dev/null +++ b/packages/propel/src/components/toolbar/toolbar.tsx @@ -0,0 +1,22 @@ +import type * as React from "react"; + +import { Toolbar as ToolbarRoot, type ToolbarProps as ToolbarRootProps } from "../../ui/toolbar"; +import { ToolbarDensityContext } from "../../ui/toolbar/toolbar-context"; + +export type ToolbarProps = ToolbarRootProps & { + children?: React.ReactNode; +}; + +/** + * The ready-made toolbar: shares its `density` with the controls inside via context (so each + * control packs to match), composed around the single-element `ui/toolbar` row. + */ +export function Toolbar({ density, children, ...props }: ToolbarProps) { + return ( + + + {children} + + + ); +} diff --git a/packages/propel/src/ui/toolbar/toolbar.stories.tsx b/packages/propel/src/ui/toolbar/toolbar.stories.tsx index 1dc2e8f7..1f20c12e 100644 --- a/packages/propel/src/ui/toolbar/toolbar.stories.tsx +++ b/packages/propel/src/ui/toolbar/toolbar.stories.tsx @@ -25,12 +25,12 @@ import { ToolbarToggle, ToolbarToggleGroup, } from "./index"; +import { ToolbarDensityContext } from "./toolbar-context"; -// UI-tier story: composes the ATOMIC toolbar parts (each renders a single element). The icon inside -// every control is its own `ToolbarItemIcon` slot, and the menu trigger pairs a -// `ToolbarMenuTriggerLabel` with a `ToolbarMenuTriggerIndicator` — the controls hold no raw -// glyph sizing or label typography. The components-tier `Toolbar` story shows the ready-made -// `ToolbarMenu` and `ToolbarMenuTrigger` that compose these parts for you. +// UI-tier story: composes the ATOMIC toolbar parts (each renders a single element). `Toolbar` is a +// single element; its `density` styles the row, and is wired to the controls inside via the +// `ToolbarDensityContext` provider explicitly here (the components-tier `Toolbar` does this for you +// via its ready-made). Every control's icon is its own `ToolbarItemIcon` slot. const meta = { title: "UI/Toolbar", component: Toolbar, @@ -58,66 +58,68 @@ type Story = StoryObj; */ export const Default: Story = { render: (args) => ( - - - Text - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + Text + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - + + - + - - - - - - - - - - - - - - + + + ), play: async ({ canvas, userEvent }) => { // The root carries the toolbar role, and a toggle flips its pressed state on click. @@ -138,24 +140,26 @@ export const Elevations: Story = { render: (args) => (
    {(["raised", "flat"] as const).map((elevation) => ( - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + ))}
    ), @@ -170,24 +174,26 @@ export const Densities: Story = { render: (args) => (
    {(["compact", "comfortable"] as const).map((density) => ( - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + ))}
    ), diff --git a/packages/propel/src/ui/toolbar/toolbar.tsx b/packages/propel/src/ui/toolbar/toolbar.tsx index 73e6f43b..6d131111 100644 --- a/packages/propel/src/ui/toolbar/toolbar.tsx +++ b/packages/propel/src/ui/toolbar/toolbar.tsx @@ -1,10 +1,6 @@ import { Toolbar as BaseToolbar } from "@base-ui/react/toolbar"; -import { - ToolbarDensityContext, - type ToolbarDensity, - type ToolbarElevation, -} from "./toolbar-context"; +import { type ToolbarDensity, type ToolbarElevation } from "./toolbar-context"; import { toolbarVariants } from "./variants"; export type { ToolbarDensity, ToolbarElevation } from "./toolbar-context"; @@ -16,11 +12,11 @@ export type ToolbarProps = Omit & density: ToolbarDensity; }; -/** A row of controls built on Base UI's `Toolbar`. */ +/** + * A row of controls built on Base UI's `Toolbar` — a single element. The density-sharing behavior + * (so the controls inside pick up the toolbar's `density`) is the ready-made `components/toolbar`, + * which wraps this in the `ToolbarDensityContext` provider. + */ export function Toolbar({ elevation, density, ...props }: ToolbarProps) { - return ( - - - - ); + return ; } From 25c3ff545beb40ed757968288d00de6eaba51483 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Wed, 24 Jun 2026 19:58:21 +0700 Subject: [PATCH 05/86] tabs: single-element ui parts; move composition to components 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. --- packages/propel/src/components/tabs/index.tsx | 5 +- .../propel/src/components/tabs/tabs-list.tsx | 35 ++++++--- packages/propel/src/components/tabs/tabs.tsx | 22 ++++++ packages/propel/src/ui/tabs/index.tsx | 1 + .../src/ui/tabs/tabs-list-scroll-area.tsx | 14 ++++ packages/propel/src/ui/tabs/tabs-list.tsx | 19 ++--- packages/propel/src/ui/tabs/tabs.stories.tsx | 74 ++++++++++--------- packages/propel/src/ui/tabs/tabs.tsx | 16 ++-- packages/propel/src/ui/tabs/variants.ts | 18 +++-- 9 files changed, 125 insertions(+), 79 deletions(-) create mode 100644 packages/propel/src/components/tabs/tabs.tsx create mode 100644 packages/propel/src/ui/tabs/tabs-list-scroll-area.tsx diff --git a/packages/propel/src/components/tabs/index.tsx b/packages/propel/src/components/tabs/index.tsx index 01a45eb4..8e307ae2 100644 --- a/packages/propel/src/components/tabs/index.tsx +++ b/packages/propel/src/components/tabs/index.tsx @@ -1,12 +1,13 @@ export { Tab, type TabProps } from "./tab"; +export { Tabs, type TabsProps } from "./tabs"; export { TabsList, type TabsListProps } from "./tabs-list"; // Re-export the atomic structural parts so a full tab set is importable from this convenience. export { - Tabs, TabsIndicator, type TabsIndicatorProps, + TabsListScrollArea, + type TabsListScrollAreaProps, TabsPanel, type TabsPanelProps, - type TabsProps, type TabsVariant, } from "../../ui/tabs"; diff --git a/packages/propel/src/components/tabs/tabs-list.tsx b/packages/propel/src/components/tabs/tabs-list.tsx index 1eea9b92..00c767f2 100644 --- a/packages/propel/src/components/tabs/tabs-list.tsx +++ b/packages/propel/src/components/tabs/tabs-list.tsx @@ -1,22 +1,33 @@ import { ScrollArea as BaseScrollArea } from "@base-ui/react/scroll-area"; +import * as React from "react"; -import { scrollbarClass, scrollbarThumbClass } from "../../internal/scrollbar"; -import { TabsList as TabsListRoot, type TabsListProps as TabsListRootProps } from "../../ui/tabs"; +import { ScrollAreaScrollbar, ScrollAreaThumb } from "../../ui/scroll-area"; +import { + TabsIndicator, + TabsList as TabsListRoot, + type TabsListProps as TabsListRootProps, + TabsListScrollArea, +} from "../../ui/tabs"; +import { TabsVariantContext } from "../../ui/tabs/tabs-context"; export type TabsListProps = TabsListRootProps; /** - * The ready-made tab strip: composes the atomic `TabsList` inside a Base UI `ScrollArea` so a long - * row of tabs scrolls horizontally with propel's overlay scrollbar. The atomic `TabsList` renders - * as the scroll viewport. + * The ready-made tab strip: composes the atomic `TabsList` (rendered as the scroll viewport) inside + * a horizontal `TabsListScrollArea` so a long row of tabs scrolls, and renders the active-tab + * underline `TabsIndicator` for the `underline` variant. */ -export function TabsList(props: TabsListProps) { +export function TabsList({ children, ...props }: TabsListProps) { + const variant = React.useContext(TabsVariantContext); return ( - - } {...props} /> - - - - + + } {...props}> + {children} + {variant === "underline" ? : null} + + + + + ); } diff --git a/packages/propel/src/components/tabs/tabs.tsx b/packages/propel/src/components/tabs/tabs.tsx new file mode 100644 index 00000000..9d166d7e --- /dev/null +++ b/packages/propel/src/components/tabs/tabs.tsx @@ -0,0 +1,22 @@ +import type * as React from "react"; + +import { Tabs as TabsRoot, type TabsProps as TabsRootProps } from "../../ui/tabs"; +import { TabsVariantContext } from "../../ui/tabs/tabs-context"; + +export type TabsProps = TabsRootProps & { + children?: React.ReactNode; +}; + +/** + * The ready-made tab set: shares its `variant` with the `TabsList`/`Tab`s inside via context, + * composed around the single-element `ui/tabs` root. + */ +export function Tabs({ variant, children, ...props }: TabsProps) { + return ( + + + {children} + + + ); +} diff --git a/packages/propel/src/ui/tabs/index.tsx b/packages/propel/src/ui/tabs/index.tsx index 1dac9d96..8af1206d 100644 --- a/packages/propel/src/ui/tabs/index.tsx +++ b/packages/propel/src/ui/tabs/index.tsx @@ -4,4 +4,5 @@ export { TabUnderlineLabel, type TabUnderlineLabelProps } from "./tab-underline- export { Tabs, type TabsProps, type TabsVariant } from "./tabs"; export { TabsIndicator, type TabsIndicatorProps } from "./tabs-indicator"; export { TabsList, type TabsListProps } from "./tabs-list"; +export { TabsListScrollArea, type TabsListScrollAreaProps } from "./tabs-list-scroll-area"; export { TabsPanel, type TabsPanelProps } from "./tabs-panel"; diff --git a/packages/propel/src/ui/tabs/tabs-list-scroll-area.tsx b/packages/propel/src/ui/tabs/tabs-list-scroll-area.tsx new file mode 100644 index 00000000..4dc93ca0 --- /dev/null +++ b/packages/propel/src/ui/tabs/tabs-list-scroll-area.tsx @@ -0,0 +1,14 @@ +import { ScrollArea as BaseScrollArea } from "@base-ui/react/scroll-area"; + +import { tabsListScrollAreaVariants } from "./variants"; + +export type TabsListScrollAreaProps = Omit; + +/** + * The horizontal scroll frame around a `TabsList` (a single `ScrollArea.Root`). The ready-made + * `components/tabs` `TabsList` composes this with the list (as the scroll viewport) and a scrollbar + * so a long row of tabs scrolls sideways. + */ +export function TabsListScrollArea(props: TabsListScrollAreaProps) { + return ; +} diff --git a/packages/propel/src/ui/tabs/tabs-list.tsx b/packages/propel/src/ui/tabs/tabs-list.tsx index fe16f8e6..ade73ef6 100644 --- a/packages/propel/src/ui/tabs/tabs-list.tsx +++ b/packages/propel/src/ui/tabs/tabs-list.tsx @@ -1,26 +1,17 @@ import { Tabs as BaseTabs } from "@base-ui/react/tabs"; -import { cx } from "class-variance-authority"; import * as React from "react"; import { TabsVariantContext } from "./tabs-context"; -import { TabsIndicator } from "./tabs-indicator"; import { tabsListVariants } from "./variants"; export type TabsListProps = Omit; /** - * The row of tabs (Base UI `Tabs.List`). Renders the active-tab `TabsIndicator` for the underline - * variant. + * The row of tabs (Base UI `Tabs.List`) — a single element. Reads the set's `variant` from context + * for its chrome. The ready-made `components/tabs` `TabsList` adds the horizontal scroller and the + * underline `TabsIndicator`. */ -export function TabsList({ children, ...props }: TabsListProps) { +export function TabsList(props: TabsListProps) { const variant = React.useContext(TabsVariantContext); - return ( - - {children} - {variant === "underline" ? : null} - - ); + return ; } diff --git a/packages/propel/src/ui/tabs/tabs.stories.tsx b/packages/propel/src/ui/tabs/tabs.stories.tsx index a9eff6b0..e8841bde 100644 --- a/packages/propel/src/ui/tabs/tabs.stories.tsx +++ b/packages/propel/src/ui/tabs/tabs.stories.tsx @@ -10,18 +10,17 @@ import { TabsList, TabsPanel, } from "./index"; +import { TabsVariantContext } from "./tabs-context"; -// UI-tier story: composes the ATOMIC tab parts. `Tabs` (Base UI `Tabs.Root`) tracks the -// active tab and shares its `variant` via context; `TabsList` rows up the `Tab`s and renders -// the `TabsIndicator` underline bar for the underline variant; `TabsPanel` shows the content -// for the active value. `TabUnderlineLabel`/`TabUnderlineBar` are the decorative inner parts -// of an underline-variant tab. The ready-made tab set lives in `components/tabs`. +// UI-tier story: composes the ATOMIC tab parts (each a single element). `Tabs` tracks the active +// tab; `TabsList` rows up the `Tab`s; `TabsPanel` shows the active content; `TabsIndicator` is the +// underline bar. The set's `variant` is wired to the parts via `TabsVariantContext` explicitly here +// (the ready-made `components/tabs` does this via its provider, and adds the horizontal scroll +// frame + the indicator for you). const meta = { title: "UI/Tabs", component: Tabs, subcomponents: { TabsList, Tab, TabsIndicator, TabsPanel }, - // The render fns assemble their own Tabs root with an explicit variant; this satisfies the - // required `variant` axis on the meta component type. args: { variant: "contained" }, } satisfies Meta; @@ -37,20 +36,22 @@ const TAB_ITEMS = [ /** Assemble the atomic parts: Root › List › Tab, plus a Panel per value (contained variant). */ export const Default: Story = { render: () => ( - - + + + + {TAB_ITEMS.map((item) => ( + + {item.label} + + ))} + {TAB_ITEMS.map((item) => ( - - {item.label} - + + {item.panel} + ))} - - {TAB_ITEMS.map((item) => ( - - {item.panel} - - ))} - + + ), play: async ({ canvas, userEvent }) => { const overview = canvas.getByRole("tab", { name: "Overview" }); @@ -63,26 +64,29 @@ export const Default: Story = { }; /** - * The underline variant: `TabsList` renders the shared `TabsIndicator` that slides under the active - * tab. Each `Tab` decorates its body with the atomic `TabUnderlineLabel` (the rounded label box) - * and `TabUnderlineBar` (the per-tab hover bar the indicator hands off to when active). + * The underline variant: compose the shared `TabsIndicator` inside the `TabsList` (the ready-made + * `components/tabs` adds it for you). Each `Tab` decorates its body with the atomic + * `TabUnderlineLabel` (the rounded label box) and `TabUnderlineBar` (the per-tab hover bar). */ export const Underline: Story = { render: () => ( - - + + + + {TAB_ITEMS.map((item) => ( + + {item.label} + + + ))} + + {TAB_ITEMS.map((item) => ( - - {item.label} - - + + {item.panel} + ))} - - {TAB_ITEMS.map((item) => ( - - {item.panel} - - ))} - + + ), }; diff --git a/packages/propel/src/ui/tabs/tabs.tsx b/packages/propel/src/ui/tabs/tabs.tsx index 6def4b13..f7c0f9ad 100644 --- a/packages/propel/src/ui/tabs/tabs.tsx +++ b/packages/propel/src/ui/tabs/tabs.tsx @@ -1,6 +1,6 @@ import { Tabs as BaseTabs } from "@base-ui/react/tabs"; -import { TabsVariantContext, type TabsVariant } from "./tabs-context"; +import { type TabsVariant } from "./tabs-context"; import { rootVariants } from "./variants"; export type { TabsVariant } from "./tabs-context"; @@ -8,20 +8,16 @@ export type { TabsVariant } from "./tabs-context"; export type TabsProps = Omit & { /** * Visual treatment (Figma variant). `contained` lifts the active tab onto a raised card inside a - * pill; `underline` slides a dark bar under it. Required, with no silent default, like the other - * essential axes (e.g. Switch `magnitude`). + * pill; `underline` slides a dark bar under it. Required, with no silent default. */ variant: TabsVariant; }; /** - * Root of a tab set. Groups a `TabsList` of `Tab`s with their `TabsPanel`s and tracks which tab is - * active. + * Root of a tab set (Base UI `Tabs.Root`) — a single element. Groups a `TabsList` of `Tab`s with + * their `TabsPanel`s and tracks the active tab. The variant-sharing context (so the list/tabs pick + * up the set's `variant`) is the ready-made `components/tabs`. */ export function Tabs({ variant, ...props }: TabsProps) { - return ( - - - - ); + return ; } diff --git a/packages/propel/src/ui/tabs/variants.ts b/packages/propel/src/ui/tabs/variants.ts index bc8d0773..fc9a8df8 100644 --- a/packages/propel/src/ui/tabs/variants.ts +++ b/packages/propel/src/ui/tabs/variants.ts @@ -9,14 +9,20 @@ export const rootVariants = cva("inline-flex max-w-full flex-col items-start gap }, }); -export const tabsListVariants = cva("relative inline-flex max-w-full", { - variants: { - variant: { - contained: "items-center gap-px rounded-lg bg-layer-3 p-0.5", - underline: "items-start gap-px px-0.5", +export const tabsListVariants = cva( + "relative inline-flex max-w-full overscroll-x-contain outline-none", + { + variants: { + variant: { + contained: "items-center gap-px rounded-lg bg-layer-3 p-0.5", + underline: "items-start gap-px px-0.5", + }, }, }, -}); +); + +// The horizontal scroll frame around a `TabsList` so a long tab row scrolls. +export const tabsListScrollAreaVariants = cva("relative max-w-full"); export const tabVariants = cva( // `--node-size` sizes any leading icon slot (the tab owns its node sizing in one place, From 20d4cf8a2564f91968d06b1ec1d7e36aca0b62a5 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Wed, 24 Jun 2026 20:05:37 +0700 Subject: [PATCH 06/86] table: single-element ui parts; move scroll frame + variant provider to components ui Table is now a single render-capable
    `). Borders follow the surrounding `Table`'s variant. */ -export function TableCell({ pinned, padding = "cell", ...props }: TableCellProps) { +export function TableCell({ pinned, padding = "cell", render, ...props }: TableCellProps) { const surface = useTableVariant(); - return ( - - ); + const defaultProps: useRender.ElementProps<"td"> = { + className: tableCellVariants({ surface, pinned: pinned ?? "none", padding }), + }; + return useRender({ defaultTagName: "td", render, props: mergeProps(defaultProps, props) }); } diff --git a/packages/propel/src/ui/table/table-head.tsx b/packages/propel/src/ui/table/table-head.tsx index 8ed24d4f..8b6fe8fb 100644 --- a/packages/propel/src/ui/table/table-head.tsx +++ b/packages/propel/src/ui/table/table-head.tsx @@ -1,9 +1,10 @@ -import type * as React from "react"; +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; import { TablePinned, useTableVariant } from "./table-context"; import { tableHeadVariants } from "./variants"; -export type TableHeadProps = Omit, "className" | "style"> & { +export type TableHeadProps = Omit, "className" | "style"> & { /** Pin this header to the inline-start/end edge when the table scrolls sideways. */ pinned?: TablePinned; }; @@ -12,13 +13,11 @@ export type TableHeadProps = Omit, "className" | "sty * A header cell (``). Borders follow the surrounding `Table`'s variant. Holds a * `TableHeadTitle` (or, when sortable, a `TableHeadSortTrigger`). */ -export function TableHead({ pinned, ...props }: TableHeadProps) { +export function TableHead({ pinned, render, ...props }: TableHeadProps) { const surface = useTableVariant(); - return ( - - ); + const defaultProps: useRender.ElementProps<"th"> = { + scope: "col", + className: tableHeadVariants({ surface, pinned: pinned ?? "none" }), + }; + return useRender({ defaultTagName: "th", render, props: mergeProps(defaultProps, props) }); } diff --git a/packages/propel/src/ui/table/table-header.tsx b/packages/propel/src/ui/table/table-header.tsx index dba53950..1ca5666c 100644 --- a/packages/propel/src/ui/table/table-header.tsx +++ b/packages/propel/src/ui/table/table-header.tsx @@ -1,10 +1,12 @@ -import type * as React from "react"; +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; import { tableHeaderVariants } from "./variants"; -export type TableHeaderProps = Omit, "className" | "style">; +export type TableHeaderProps = Omit, "className" | "style">; /** Header section (`
    . 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. --- .../propel/src/components/table/index.tsx | 3 +- .../propel/src/components/table/table.tsx | 40 +++ packages/propel/src/ui/table/index.tsx | 5 + .../ui/table/table-scroll-area-viewport.tsx | 16 + .../propel/src/ui/table/table-scroll-area.tsx | 14 + .../propel/src/ui/table/table.stories.tsx | 325 +++++++++--------- packages/propel/src/ui/table/table.tsx | 39 +-- packages/propel/src/ui/table/variants.ts | 4 +- 8 files changed, 256 insertions(+), 190 deletions(-) create mode 100644 packages/propel/src/components/table/table.tsx create mode 100644 packages/propel/src/ui/table/table-scroll-area-viewport.tsx create mode 100644 packages/propel/src/ui/table/table-scroll-area.tsx diff --git a/packages/propel/src/components/table/index.tsx b/packages/propel/src/components/table/index.tsx index 94510554..21f021cc 100644 --- a/packages/propel/src/components/table/index.tsx +++ b/packages/propel/src/components/table/index.tsx @@ -1,7 +1,6 @@ +export { Table, type TableProps } from "./table"; export { - Table, type TablePinned, - type TableProps, type TableVariant, TableBody, type TableBodyProps, diff --git a/packages/propel/src/components/table/table.tsx b/packages/propel/src/components/table/table.tsx new file mode 100644 index 00000000..86768a19 --- /dev/null +++ b/packages/propel/src/components/table/table.tsx @@ -0,0 +1,40 @@ +import type * as React from "react"; + +import { ScrollAreaCorner, ScrollAreaScrollbar, ScrollAreaThumb } from "../../ui/scroll-area"; +import { + Table as TableRoot, + type TableProps as TableRootProps, + TableScrollArea, + type TableVariant, + TableScrollAreaViewport, +} from "../../ui/table"; +import { TableVariantContext } from "../../ui/table/table-context"; + +export type TableProps = TableRootProps & { + /** Layout (required). `table` draws row dividers only; `spreadsheet` draws a full grid. */ + variant: TableVariant; + children?: React.ReactNode; +}; + +/** + * The ready-made table: the styled `
    ` wrapped in a rounded, hairline-bordered scroll frame, + * sharing its layout `variant` with the cells/heads via context. + */ +export function Table({ variant, children, ...props }: TableProps) { + return ( + + + + {children} + + + + + + + + + + + ); +} diff --git a/packages/propel/src/ui/table/index.tsx b/packages/propel/src/ui/table/index.tsx index 84f05fcb..45351136 100644 --- a/packages/propel/src/ui/table/index.tsx +++ b/packages/propel/src/ui/table/index.tsx @@ -1,4 +1,9 @@ export { Table, type TablePinned, type TableProps, type TableVariant } from "./table"; +export { TableScrollArea, type TableScrollAreaProps } from "./table-scroll-area"; +export { + TableScrollAreaViewport, + type TableScrollAreaViewportProps, +} from "./table-scroll-area-viewport"; export { TableBody, type TableBodyProps } from "./table-body"; export { TableCell, type TableCellPadding, type TableCellProps } from "./table-cell"; export { TableCellContent, type TableCellContentProps } from "./table-cell-content"; diff --git a/packages/propel/src/ui/table/table-scroll-area-viewport.tsx b/packages/propel/src/ui/table/table-scroll-area-viewport.tsx new file mode 100644 index 00000000..a9b026fe --- /dev/null +++ b/packages/propel/src/ui/table/table-scroll-area-viewport.tsx @@ -0,0 +1,16 @@ +import { ScrollArea as BaseScrollArea } from "@base-ui/react/scroll-area"; + +import { tableScrollAreaViewportVariants } from "./variants"; + +export type TableScrollAreaViewportProps = Omit< + BaseScrollArea.Viewport.Props, + "className" | "style" +>; + +/** + * The scroll viewport inside a `TableScrollArea` that holds the `
    ` (a single + * `ScrollArea.Viewport`). + */ +export function TableScrollAreaViewport(props: TableScrollAreaViewportProps) { + return ; +} diff --git a/packages/propel/src/ui/table/table-scroll-area.tsx b/packages/propel/src/ui/table/table-scroll-area.tsx new file mode 100644 index 00000000..d98d064f --- /dev/null +++ b/packages/propel/src/ui/table/table-scroll-area.tsx @@ -0,0 +1,14 @@ +import { ScrollArea as BaseScrollArea } from "@base-ui/react/scroll-area"; + +import { tableScrollAreaVariants } from "./variants"; + +export type TableScrollAreaProps = Omit; + +/** + * The rounded, hairline-bordered scroll frame around a `Table` (a single `ScrollArea.Root`). The + * ready-made `components/table` composes this with the `TableScrollAreaViewport`, the `
    `, + * and the scrollbars. + */ +export function TableScrollArea(props: TableScrollAreaProps) { + return ; +} diff --git a/packages/propel/src/ui/table/table.stories.tsx b/packages/propel/src/ui/table/table.stories.tsx index 0b75085d..24b77343 100644 --- a/packages/propel/src/ui/table/table.stories.tsx +++ b/packages/propel/src/ui/table/table.stories.tsx @@ -18,6 +18,7 @@ import { TableHeadTitle, TableRow, } from "./index"; +import { TableVariantContext } from "./table-context"; // UI-tier story: composes the ATOMIC table parts (each renders a single table element). // The components-tier `Table` story shows the ready-made `TableHead`/`TableCell` (which compose @@ -40,7 +41,6 @@ const meta = { TableCellContent, TableCellSlot, }, - args: { variant: "table" }, } satisfies Meta; export default meta; @@ -58,130 +58,16 @@ const COLUMNS = ["Name", "Display name", "Email", "Account type"]; /** The standard `table` variant: rounded outer border with row dividers only. */ export const Default: Story = { - render: (args) => ( -
    - - - {COLUMNS.map((c) => ( - - {c} - - ))} - - - - {PEOPLE.map((person) => ( - - - - - - {person.name.charAt(0)} - - - {person.name} - - - - - {person.display} - - - - - {person.email} - - - - - {person.role} - - - - ))} - -
    - ), - play: async ({ canvas }) => { - await expect(canvas.getAllByRole("columnheader")).toHaveLength(4); - // 1 header row + 3 data rows. - await expect(canvas.getAllByRole("row")).toHaveLength(4); - }, -}; - -/** The denser `spreadsheet` variant: every cell is fully bordered to form a grid. */ -export const Spreadsheet: Story = { - args: { variant: "spreadsheet" }, - render: (args) => ( - - - - {COLUMNS.map((c) => ( - - {c} - - ))} - - - - {PEOPLE.map((person) => ( - - - - {person.name} - - - - - {person.display} - - - - - {person.email} - - - - - {person.role} - - - - ))} - -
    - ), -}; - -const sortGlyph = { asc: ChevronUp, desc: ChevronDown, none: ChevronsUpDown } as const; -const ariaSort = { asc: "ascending", desc: "descending", none: "none" } as const; -type Sort = keyof typeof sortGlyph; - -/** - * A sortable header, assembled from the atomic parts: a `TableHeadSortTrigger` button wrapping a - * `TableHeadTitle` and a `TableHeadSortIndicator` chevron, with `aria-sort` on the ``. Clicking - * cycles none → asc → desc. - */ -export const Sortable: Story = { - render: function SortableStory(args) { - const [sort, setSort] = React.useState("none"); - const cycle = () => setSort((s) => (s === "none" ? "asc" : s === "asc" ? "desc" : "none")); - const SortGlyph = sortGlyph[sort]; - return ( - + render: () => ( + +
    - - - Name - - - - - - - Email - + {COLUMNS.map((c) => ( + + {c} + + ))} @@ -189,66 +75,61 @@ export const Sortable: Story = { + + + {person.name.charAt(0)} + + {person.name} + + + {person.display} + + {person.email} + + + {person.role} + + ))}
    - ); - }, + + ), play: async ({ canvas }) => { - const header = canvas.getAllByRole("columnheader")[0]; - await expect(header).toHaveAttribute("aria-sort", "none"); - const button = canvas.getByRole("button", { name: "Name" }); - await userEvent.click(button); - await expect(header).toHaveAttribute("aria-sort", "ascending"); - await userEvent.click(button); - await expect(header).toHaveAttribute("aria-sort", "descending"); + await expect(canvas.getAllByRole("columnheader")).toHaveLength(4); + // 1 header row + 3 data rows. + await expect(canvas.getAllByRole("row")).toHaveLength(4); }, }; -/** - * A pinned first column (`pinned="start"` on its header + cells) stays put while the rest of the - * row scrolls sideways inside a width-constrained frame. - */ -export const PinnedColumn: Story = { - parameters: { controls: { disable: true } }, - render: (args) => ( -
    - +/** The denser `spreadsheet` variant: every cell is fully bordered to form a grid. */ +export const Spreadsheet: Story = { + render: () => ( + +
    - - Name - - - Display name - - - Email - - - Account type - + {COLUMNS.map((c) => ( + + {c} + + ))} {PEOPLE.map((person) => ( - + - - - {person.name.charAt(0)} - - {person.name} @@ -271,6 +152,132 @@ export const PinnedColumn: Story = { ))}
    + + ), +}; + +const sortGlyph = { asc: ChevronUp, desc: ChevronDown, none: ChevronsUpDown } as const; +const ariaSort = { asc: "ascending", desc: "descending", none: "none" } as const; +type Sort = keyof typeof sortGlyph; + +/** + * A sortable header, assembled from the atomic parts: a `TableHeadSortTrigger` button wrapping a + * `TableHeadTitle` and a `TableHeadSortIndicator` chevron, with `aria-sort` on the ``. Clicking + * cycles none → asc → desc. + */ +export const Sortable: Story = { + render: function SortableStory() { + const [sort, setSort] = React.useState("none"); + const cycle = () => setSort((s) => (s === "none" ? "asc" : s === "asc" ? "desc" : "none")); + const SortGlyph = sortGlyph[sort]; + return ( + + + + + + + Name + + + + + + + Email + + + + + {PEOPLE.map((person) => ( + + + + {person.name} + + + + + {person.email} + + + + ))} + +
    +
    + ); + }, + play: async ({ canvas }) => { + const header = canvas.getAllByRole("columnheader")[0]; + await expect(header).toHaveAttribute("aria-sort", "none"); + const button = canvas.getByRole("button", { name: "Name" }); + await userEvent.click(button); + await expect(header).toHaveAttribute("aria-sort", "ascending"); + await userEvent.click(button); + await expect(header).toHaveAttribute("aria-sort", "descending"); + }, +}; + +/** + * A pinned first column (`pinned="start"` on its header + cells) stays put while the rest of the + * row scrolls sideways inside a width-constrained frame. + */ +export const PinnedColumn: Story = { + parameters: { controls: { disable: true } }, + render: () => ( +
    + + + + + + Name + + + Display name + + + Email + + + Account type + + + + + {PEOPLE.map((person) => ( + + + + + + {person.name.charAt(0)} + + + {person.name} + + + + + {person.display} + + + + + {person.email} + + + + + {person.role} + + + + ))} + +
    +
    ), }; diff --git a/packages/propel/src/ui/table/table.tsx b/packages/propel/src/ui/table/table.tsx index fd1fff82..c624dbbf 100644 --- a/packages/propel/src/ui/table/table.tsx +++ b/packages/propel/src/ui/table/table.tsx @@ -1,33 +1,18 @@ -import { ScrollArea as BaseScrollArea } from "@base-ui/react/scroll-area"; -import type * as React from "react"; +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; -import { scrollbarClass, scrollbarThumbClass } from "../../internal/scrollbar"; -import { TableVariantContext, type TableVariant } from "./table-context"; -import { tableRootVariants, tableVariants, tableViewportVariants } from "./variants"; +import { tableVariants } from "./variants"; export type { TablePinned, TableVariant } from "./table-context"; -export type TableProps = Omit, "className" | "style"> & { - /** Layout (required). `table` draws row dividers only; `spreadsheet` draws a full grid. */ - variant: TableVariant; -}; +export type TableProps = Omit, "className" | "style">; -/** Root ``, wrapped in a rounded, hairline-bordered scroll frame. */ -export function Table({ variant, ...props }: TableProps) { - return ( - - - -
    - - - - - - - - - - - ); +/** + * The styled `
    ` element — a single element. The ready-made `components/table` wraps it in + * the rounded scroll frame (`TableScrollArea` + `TableScrollAreaViewport`) and shares the layout + * `variant` with the cells/heads via context. + */ +export function Table({ render, ...props }: TableProps) { + const defaultProps: useRender.ElementProps<"table"> = { className: tableVariants() }; + return useRender({ defaultTagName: "table", render, props: mergeProps(defaultProps, props) }); } diff --git a/packages/propel/src/ui/table/variants.ts b/packages/propel/src/ui/table/variants.ts index ecb863b8..037b7b6a 100644 --- a/packages/propel/src/ui/table/variants.ts +++ b/packages/propel/src/ui/table/variants.ts @@ -9,12 +9,12 @@ import { nodeSlotClass } from "../../internal/node-slot"; // a cva pairing, so no part takes a `className` at its boundary. // The rounded, hairline scroll frame around the `
    ` (Base UI ScrollArea root). -export const tableRootVariants = cva( +export const tableScrollAreaVariants = cva( "relative flex max-h-full w-full flex-col overflow-hidden rounded-lg border border-subtle bg-surface-1", ); // The scroll viewport that the `
    ` lives in. -export const tableViewportVariants = cva( +export const tableScrollAreaViewportVariants = cva( cx( "min-h-0 flex-1 overscroll-contain rounded-[inherit] outline-none", "focus-visible:ring-2 focus-visible:ring-accent-strong focus-visible:ring-inset", From 1ad485a16ec9999131afd0b084da480b9b27f1ee Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Wed, 24 Jun 2026 20:09:20 +0700 Subject: [PATCH 07/86] Format toggle-group components file (slipped a scoped --fix) --- packages/propel/src/components/toggle-group/toggle-group.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/propel/src/components/toggle-group/toggle-group.tsx b/packages/propel/src/components/toggle-group/toggle-group.tsx index a2cbe575..e0c9326c 100644 --- a/packages/propel/src/components/toggle-group/toggle-group.tsx +++ b/packages/propel/src/components/toggle-group/toggle-group.tsx @@ -1,11 +1,11 @@ import type * as React from "react"; -import { ToggleGroupContext } from "../../ui/toggle/toggle-group-context"; -import type { ToggleMagnitude } from "../../ui/toggle/variants"; import { ToggleGroup as ToggleGroupRoot, type ToggleGroupProps as ToggleGroupRootProps, } from "../../ui/toggle-group"; +import { ToggleGroupContext } from "../../ui/toggle/toggle-group-context"; +import type { ToggleMagnitude } from "../../ui/toggle/variants"; export type ToggleGroupProps = ToggleGroupRootProps & { /** Size applied to every `Toggle` in the group (each `Toggle` can still override it). */ From 3907e6c635f6249fc835e4447852f8e4290b457b Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Wed, 24 Jun 2026 20:15:40 +0700 Subject: [PATCH 08/86] avatar: move AvatarGroupContext to components (context = composition 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. --- .../components/avatar-group/avatar-group.tsx | 3 ++- .../components/avatar/avatar-group-context.ts | 10 +++++++++ .../propel/src/components/avatar/avatar.tsx | 4 ++-- packages/propel/src/ui/avatar/avatar.tsx | 21 ++++--------------- 4 files changed, 18 insertions(+), 20 deletions(-) create mode 100644 packages/propel/src/components/avatar/avatar-group-context.ts diff --git a/packages/propel/src/components/avatar-group/avatar-group.tsx b/packages/propel/src/components/avatar-group/avatar-group.tsx index 62b135a3..06c70abd 100644 --- a/packages/propel/src/components/avatar-group/avatar-group.tsx +++ b/packages/propel/src/components/avatar-group/avatar-group.tsx @@ -1,6 +1,7 @@ import type * as React from "react"; -import { AvatarGroupContext, type AvatarMagnitude } from "../../ui/avatar"; +import { type AvatarMagnitude } from "../../ui/avatar"; +import { AvatarGroupContext } from "../avatar/avatar-group-context"; import { AvatarGroup as AvatarGroupRoot, type AvatarGroupProps as AvatarGroupRootProps, diff --git a/packages/propel/src/components/avatar/avatar-group-context.ts b/packages/propel/src/components/avatar/avatar-group-context.ts new file mode 100644 index 00000000..af2ea9d9 --- /dev/null +++ b/packages/propel/src/components/avatar/avatar-group-context.ts @@ -0,0 +1,10 @@ +import * as React from "react"; + +import type { AvatarMagnitude } from "../../ui/avatar"; + +/** + * Set by the components `AvatarGroup` so every `Avatar` inside it shares one `magnitude` (an + * avatar's own `magnitude` still wins). Lives in the components tier: a context is cross-tree + * coordination — composition — not a single-element `ui` concern. + */ +export const AvatarGroupContext = React.createContext(undefined); diff --git a/packages/propel/src/components/avatar/avatar.tsx b/packages/propel/src/components/avatar/avatar.tsx index 05ec4e9c..5e03a2ce 100644 --- a/packages/propel/src/components/avatar/avatar.tsx +++ b/packages/propel/src/components/avatar/avatar.tsx @@ -4,13 +4,13 @@ import * as React from "react"; import { Avatar as AvatarRoot, AvatarFallback, - AvatarGroupContext, AvatarIcon, AvatarImage, type AvatarProps as AvatarRootProps, type AvatarTone, getAvatarTone, } from "../../ui/avatar"; +import { AvatarGroupContext } from "./avatar-group-context"; export type AvatarProps = AvatarRootProps & { /** Image URL. When omitted, or while it is loading/failing, the fallback shows. */ @@ -42,7 +42,7 @@ export function Avatar({ magnitude, src, alt, fallback, tone, ...props }: Avatar return ( // `role="img"` + `aria-label` give the avatar one accessible name in every state // (image / initials / icon); the inner image is decorative. - + {src ? : null} {fallback ?? ( diff --git a/packages/propel/src/ui/avatar/avatar.tsx b/packages/propel/src/ui/avatar/avatar.tsx index 95723714..77406b49 100644 --- a/packages/propel/src/ui/avatar/avatar.tsx +++ b/packages/propel/src/ui/avatar/avatar.tsx @@ -1,17 +1,10 @@ import { Avatar as BaseAvatar } from "@base-ui/react/avatar"; import { type VariantProps } from "class-variance-authority"; -import * as React from "react"; import { avatarToneBgClass, avatarVariants } from "./variants"; export type AvatarMagnitude = NonNullable["magnitude"]>; -/** - * Set by `AvatarGroup` to give every avatar inside it the same `magnitude`, so a group stays - * consistently sized. An avatar's own `magnitude` prop takes precedence. - */ -export const AvatarGroupContext = React.createContext(undefined); - // The initials tone palette the designer defined for avatars (Figma label colors). export const AVATAR_TONES = ["orange", "indigo", "emerald", "crimson", "pink", "purple"] as const; export type AvatarTone = (typeof AVATAR_TONES)[number]; @@ -35,8 +28,8 @@ export function getAvatarTone(seed: string): AvatarTone { /** Props for {@link Avatar} (the Base UI `Avatar.Root`), plus a `magnitude`. */ export type AvatarProps = Omit & { /** - * Avatar size. Optional because an `Avatar` inside an `AvatarGroup` inherits the group's - * magnitude; standalone it falls back to `md`. + * Avatar size. Optional — defaults to `md` standalone. Inside the components `AvatarGroup` the + * group's magnitude is applied for you (shared via context from the ready-made). */ magnitude?: AvatarMagnitude; }; @@ -46,12 +39,6 @@ export type AvatarProps = Omit & { * Maps 1:1 to Base UI's `Avatar.Root`. For the ready-made image+initials+icon avatar, use the * `Avatar` from `@plane/propel/components/avatar`. */ -export function Avatar({ magnitude, ...props }: AvatarProps) { - // An explicit `magnitude` wins; otherwise inherit the group's; a standalone avatar with - // neither falls back to `md` so it always has a size. - const groupMagnitude = React.useContext(AvatarGroupContext); - const effectiveMagnitude = magnitude ?? groupMagnitude ?? "md"; - return ( - - ); +export function Avatar({ magnitude = "md", ...props }: AvatarProps) { + return ; } From 686c71594a65d9fce577abdc942e12f750d8d083 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Wed, 24 Jun 2026 20:18:24 +0700 Subject: [PATCH 09/86] tabs: move TabsVariantContext to components (context = composition concern) 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. --- packages/propel/src/components/tabs/tab.tsx | 14 ++-- .../src/components/tabs/tabs-context.ts | 10 +++ .../propel/src/components/tabs/tabs-list.tsx | 13 ++-- packages/propel/src/components/tabs/tabs.tsx | 2 +- packages/propel/src/ui/tabs/tab.tsx | 12 ++-- packages/propel/src/ui/tabs/tabs-context.tsx | 11 ---- packages/propel/src/ui/tabs/tabs-list.tsx | 17 +++-- packages/propel/src/ui/tabs/tabs.stories.tsx | 65 +++++++++---------- packages/propel/src/ui/tabs/tabs.tsx | 5 +- packages/propel/src/ui/tabs/variants.ts | 6 +- 10 files changed, 75 insertions(+), 80 deletions(-) create mode 100644 packages/propel/src/components/tabs/tabs-context.ts delete mode 100644 packages/propel/src/ui/tabs/tabs-context.tsx diff --git a/packages/propel/src/components/tabs/tab.tsx b/packages/propel/src/components/tabs/tab.tsx index 92b2bab9..c55d544d 100644 --- a/packages/propel/src/components/tabs/tab.tsx +++ b/packages/propel/src/components/tabs/tab.tsx @@ -7,9 +7,9 @@ import { TabUnderlineBar, TabUnderlineLabel, } from "../../ui/tabs"; -import { TabsVariantContext } from "../../ui/tabs/tabs-context"; +import { TabsVariantContext } from "./tabs-context"; -export type TabProps = TabRootProps & { +export type TabProps = Omit & { /** * Node rendered before the label (inline-start). Sized to the tab's `--node-size` (16px) and * tinted to the tab's text color. Decorative, kept out of the name. @@ -18,9 +18,9 @@ export type TabProps = TabRootProps & { }; /** - * The ready-made tab button: composes the atomic `Tab` and lays out an optional `inlineStartNode` - * with the label. The `underline` variant additionally renders the sliding bar track beneath the - * label. + * The ready-made tab button: composes the atomic `Tab` (taking the set's `variant` from context, so + * you don't pass it) and lays out an optional `inlineStartNode` with the label. The `underline` + * variant additionally renders the sliding bar track beneath the label. */ export function Tab({ inlineStartNode, children, ...props }: TabProps) { const variant = React.useContext(TabsVariantContext); @@ -28,7 +28,7 @@ export function Tab({ inlineStartNode, children, ...props }: TabProps) { if (variant === "underline") { return ( - + {iconNode} {children} @@ -39,7 +39,7 @@ export function Tab({ inlineStartNode, children, ...props }: TabProps) { } return ( - + {iconNode} {children} diff --git a/packages/propel/src/components/tabs/tabs-context.ts b/packages/propel/src/components/tabs/tabs-context.ts new file mode 100644 index 00000000..ee7980e9 --- /dev/null +++ b/packages/propel/src/components/tabs/tabs-context.ts @@ -0,0 +1,10 @@ +import * as React from "react"; + +import { type TabsVariant } from "../../ui/tabs"; + +/** + * Set by the components `Tabs` so the `TabsList`/`Tab`s inside share the set's `variant`. Lives in + * the components tier: a context is cross-tree coordination — composition — not a single-element + * `ui` concern. + */ +export const TabsVariantContext = React.createContext("contained"); diff --git a/packages/propel/src/components/tabs/tabs-list.tsx b/packages/propel/src/components/tabs/tabs-list.tsx index 00c767f2..3e7b1a62 100644 --- a/packages/propel/src/components/tabs/tabs-list.tsx +++ b/packages/propel/src/components/tabs/tabs-list.tsx @@ -8,20 +8,21 @@ import { type TabsListProps as TabsListRootProps, TabsListScrollArea, } from "../../ui/tabs"; -import { TabsVariantContext } from "../../ui/tabs/tabs-context"; +import { TabsVariantContext } from "./tabs-context"; -export type TabsListProps = TabsListRootProps; +export type TabsListProps = Omit; /** - * The ready-made tab strip: composes the atomic `TabsList` (rendered as the scroll viewport) inside - * a horizontal `TabsListScrollArea` so a long row of tabs scrolls, and renders the active-tab - * underline `TabsIndicator` for the `underline` variant. + * The ready-made tab strip: composes the atomic `TabsList` (taking the set's `variant` from + * context) rendered as the scroll viewport inside a horizontal `TabsListScrollArea` so a long row + * of tabs scrolls, and renders the active-tab underline `TabsIndicator` for the `underline` + * variant. */ export function TabsList({ children, ...props }: TabsListProps) { const variant = React.useContext(TabsVariantContext); return ( - } {...props}> + } {...props}> {children} {variant === "underline" ? : null} diff --git a/packages/propel/src/components/tabs/tabs.tsx b/packages/propel/src/components/tabs/tabs.tsx index 9d166d7e..e15fae3c 100644 --- a/packages/propel/src/components/tabs/tabs.tsx +++ b/packages/propel/src/components/tabs/tabs.tsx @@ -1,7 +1,7 @@ import type * as React from "react"; import { Tabs as TabsRoot, type TabsProps as TabsRootProps } from "../../ui/tabs"; -import { TabsVariantContext } from "../../ui/tabs/tabs-context"; +import { TabsVariantContext } from "./tabs-context"; export type TabsProps = TabsRootProps & { children?: React.ReactNode; diff --git a/packages/propel/src/ui/tabs/tab.tsx b/packages/propel/src/ui/tabs/tab.tsx index 6b1db2cc..c75f8b29 100644 --- a/packages/propel/src/ui/tabs/tab.tsx +++ b/packages/propel/src/ui/tabs/tab.tsx @@ -1,13 +1,13 @@ import { Tabs as BaseTabs } from "@base-ui/react/tabs"; -import * as React from "react"; -import { TabsVariantContext } from "./tabs-context"; -import { tabVariants } from "./variants"; +import { type TabsVariant, tabVariants } from "./variants"; -export type TabProps = Omit; +export type TabProps = Omit & { + /** The set's visual treatment, matching the `Tabs` root. */ + variant: TabsVariant; +}; /** A single tab button. `value` ties it to the `TabsPanel` of the same `value`. */ -export function Tab(props: TabProps) { - const variant = React.useContext(TabsVariantContext); +export function Tab({ variant, ...props }: TabProps) { return ; } diff --git a/packages/propel/src/ui/tabs/tabs-context.tsx b/packages/propel/src/ui/tabs/tabs-context.tsx deleted file mode 100644 index 06d87afd..00000000 --- a/packages/propel/src/ui/tabs/tabs-context.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { type VariantProps } from "class-variance-authority"; -import * as React from "react"; - -import { rootVariants } from "./variants"; - -// The Figma "Tabs" component defines two visual treatments. `contained` wraps -// the tabs in a pill and lifts the active tab onto a raised card; `underline` -// keeps the tabs flat and slides a dark bar under the active one. -export type TabsVariant = NonNullable["variant"]>; - -export const TabsVariantContext = React.createContext("contained"); diff --git a/packages/propel/src/ui/tabs/tabs-list.tsx b/packages/propel/src/ui/tabs/tabs-list.tsx index ade73ef6..6fe01640 100644 --- a/packages/propel/src/ui/tabs/tabs-list.tsx +++ b/packages/propel/src/ui/tabs/tabs-list.tsx @@ -1,17 +1,16 @@ import { Tabs as BaseTabs } from "@base-ui/react/tabs"; -import * as React from "react"; -import { TabsVariantContext } from "./tabs-context"; -import { tabsListVariants } from "./variants"; +import { type TabsVariant, tabsListVariants } from "./variants"; -export type TabsListProps = Omit; +export type TabsListProps = Omit & { + /** The set's visual treatment, matching the `Tabs` root. */ + variant: TabsVariant; +}; /** - * The row of tabs (Base UI `Tabs.List`) — a single element. Reads the set's `variant` from context - * for its chrome. The ready-made `components/tabs` `TabsList` adds the horizontal scroller and the - * underline `TabsIndicator`. + * The row of tabs (Base UI `Tabs.List`) — a single element. The ready-made `components/tabs` + * `TabsList` adds the horizontal scroller and the underline `TabsIndicator`. */ -export function TabsList(props: TabsListProps) { - const variant = React.useContext(TabsVariantContext); +export function TabsList({ variant, ...props }: TabsListProps) { return ; } diff --git a/packages/propel/src/ui/tabs/tabs.stories.tsx b/packages/propel/src/ui/tabs/tabs.stories.tsx index e8841bde..75a66414 100644 --- a/packages/propel/src/ui/tabs/tabs.stories.tsx +++ b/packages/propel/src/ui/tabs/tabs.stories.tsx @@ -10,13 +10,10 @@ import { TabsList, TabsPanel, } from "./index"; -import { TabsVariantContext } from "./tabs-context"; -// UI-tier story: composes the ATOMIC tab parts (each a single element). `Tabs` tracks the active -// tab; `TabsList` rows up the `Tab`s; `TabsPanel` shows the active content; `TabsIndicator` is the -// underline bar. The set's `variant` is wired to the parts via `TabsVariantContext` explicitly here -// (the ready-made `components/tabs` does this via its provider, and adds the horizontal scroll -// frame + the indicator for you). +// UI-tier story: composes the ATOMIC tab parts (each a single, prop-driven element). `Tabs`, +// `TabsList`, and each `Tab` take the `variant` explicitly — the ready-made `components/tabs` sets +// it once and shares it via context (and adds the scroll frame + indicator) so you don't repeat it. const meta = { title: "UI/Tabs", component: Tabs, @@ -36,22 +33,20 @@ const TAB_ITEMS = [ /** Assemble the atomic parts: Root › List › Tab, plus a Panel per value (contained variant). */ export const Default: Story = { render: () => ( - - - - {TAB_ITEMS.map((item) => ( - - {item.label} - - ))} - + + {TAB_ITEMS.map((item) => ( - - {item.panel} - + + {item.label} + ))} - - + + {TAB_ITEMS.map((item) => ( + + {item.panel} + + ))} + ), play: async ({ canvas, userEvent }) => { const overview = canvas.getByRole("tab", { name: "Overview" }); @@ -70,23 +65,21 @@ export const Default: Story = { */ export const Underline: Story = { render: () => ( - - - - {TAB_ITEMS.map((item) => ( - - {item.label} - - - ))} - - + + {TAB_ITEMS.map((item) => ( - - {item.panel} - + + {item.label} + + ))} - - + + + {TAB_ITEMS.map((item) => ( + + {item.panel} + + ))} + ), }; diff --git a/packages/propel/src/ui/tabs/tabs.tsx b/packages/propel/src/ui/tabs/tabs.tsx index f7c0f9ad..57917c9a 100644 --- a/packages/propel/src/ui/tabs/tabs.tsx +++ b/packages/propel/src/ui/tabs/tabs.tsx @@ -1,9 +1,8 @@ import { Tabs as BaseTabs } from "@base-ui/react/tabs"; -import { type TabsVariant } from "./tabs-context"; -import { rootVariants } from "./variants"; +import { type TabsVariant, rootVariants } from "./variants"; -export type { TabsVariant } from "./tabs-context"; +export type { TabsVariant } from "./variants"; export type TabsProps = Omit & { /** diff --git a/packages/propel/src/ui/tabs/variants.ts b/packages/propel/src/ui/tabs/variants.ts index fc9a8df8..6ee73709 100644 --- a/packages/propel/src/ui/tabs/variants.ts +++ b/packages/propel/src/ui/tabs/variants.ts @@ -1,4 +1,8 @@ -import { cva, cx } from "class-variance-authority"; +import { cva, cx, type VariantProps } from "class-variance-authority"; + +// `contained` wraps the tabs in a pill and lifts the active tab onto a raised card; `underline` +// keeps them flat and slides a dark bar under the active one. +export type TabsVariant = NonNullable["variant"]>; export const rootVariants = cva("inline-flex max-w-full flex-col items-start gap-3", { variants: { From 07762facee6b6a3b65d1ea48cf02204ce3c262e6 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Wed, 24 Jun 2026 20:25:31 +0700 Subject: [PATCH 10/86] table: move TableVariantContext + useTableVariant to components ui Table is a single
    ; 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. --- .../components/table/table-action-cell.tsx | 6 +- .../src/components/table/table-cell.tsx | 6 +- .../src/components/table/table-context.ts | 15 + .../components/table/table-editable-cell.tsx | 6 +- .../src/components/table/table-head.tsx | 10 +- .../propel/src/components/table/table.tsx | 2 +- packages/propel/src/ui/table/index.tsx | 1 - packages/propel/src/ui/table/table-cell.tsx | 12 +- .../propel/src/ui/table/table-context.tsx | 18 - packages/propel/src/ui/table/table-head.tsx | 14 +- .../propel/src/ui/table/table.stories.tsx | 323 +++++++++--------- packages/propel/src/ui/table/table.tsx | 2 +- packages/propel/src/ui/table/variants.ts | 6 + 13 files changed, 213 insertions(+), 208 deletions(-) create mode 100644 packages/propel/src/components/table/table-context.ts delete mode 100644 packages/propel/src/ui/table/table-context.tsx diff --git a/packages/propel/src/components/table/table-action-cell.tsx b/packages/propel/src/components/table/table-action-cell.tsx index ad0881c6..6912fc14 100644 --- a/packages/propel/src/components/table/table-action-cell.tsx +++ b/packages/propel/src/components/table/table-action-cell.tsx @@ -8,8 +8,9 @@ import { TableCellTrigger, TableCellTriggerIndicator, } from "../../ui/table/index"; +import { useTableVariant } from "./table-context"; -export type TableActionCellProps = Omit & { +export type TableActionCellProps = Omit & { /** The menu of row actions. */ children: React.ReactNode; /** Accessible name for the trigger (e.g. "Row options"). Required (icon-only). */ @@ -37,8 +38,9 @@ export function TableActionCell({ disabled, ...props }: TableActionCellProps) { + const variant = useTableVariant(); return ( - + & { +export type TableCellProps = Omit & { /** Leading content beside the cell text — an icon or an `Avatar`. */ inlineStartNode?: React.ReactNode; /** Trailing content beside the cell text — an icon or an `Avatar`. */ @@ -17,8 +18,9 @@ export type TableCellProps = Omit & { /** A ready-made data cell: optional leading/trailing slots around a truncating content region. */ export function TableCell({ inlineStartNode, inlineEndNode, children, ...props }: TableCellProps) { + const variant = useTableVariant(); return ( - + {inlineStartNode != null ? {inlineStartNode} : null} {children} diff --git a/packages/propel/src/components/table/table-context.ts b/packages/propel/src/components/table/table-context.ts new file mode 100644 index 00000000..79264b46 --- /dev/null +++ b/packages/propel/src/components/table/table-context.ts @@ -0,0 +1,15 @@ +import * as React from "react"; + +import { type TableVariant } from "../../ui/table"; + +/** + * Carries the root `Table`'s `variant` down to each `TableHead`/`TableCell` so they pick the + * matching borders. Lives in the components tier: a context is cross-tree coordination — + * composition — not a single-element `ui` concern. + */ +export const TableVariantContext = React.createContext("table"); + +/** Reads the surrounding `Table`'s `variant` (used by the components `TableCell`/`TableHead`). */ +export function useTableVariant(): TableVariant { + return React.useContext(TableVariantContext); +} diff --git a/packages/propel/src/components/table/table-editable-cell.tsx b/packages/propel/src/components/table/table-editable-cell.tsx index 8c844924..6db8bdd9 100644 --- a/packages/propel/src/components/table/table-editable-cell.tsx +++ b/packages/propel/src/components/table/table-editable-cell.tsx @@ -9,8 +9,9 @@ import { TableCellTriggerIndicator, TableCellTriggerLabel, } from "../../ui/table/index"; +import { useTableVariant } from "./table-context"; -export type TableEditableCellProps = Omit & { +export type TableEditableCellProps = Omit & { /** The current value shown in the cell. */ value: React.ReactNode; /** The menu shown when the cell is clicked. */ @@ -41,8 +42,9 @@ export function TableEditableCell({ "aria-label": ariaLabel, ...props }: TableEditableCellProps) { + const variant = useTableVariant(); return ( - + = { none: "none", }; -export type TableHeadProps = Omit & { +export type TableHeadProps = Omit & { /** Visual treatment. `sortable` renders the title as a sort-cycling button. */ variant: "default" | "sortable"; /** Current sort state for a sortable header. @default "none" */ @@ -35,12 +36,17 @@ export type TableHeadProps = Omit & { /** A ready-made header cell: a plain title, or (when sortable) a sort-cycling button with a chevron. */ export function TableHead({ variant, sort = "none", onSort, children, ...props }: TableHeadProps) { + const tableVariant = useTableVariant(); const isSortable = variant === "sortable"; const hasSortControl = isSortable && onSort != null; const SortGlyph = sortGlyph[sort]; return ( - + {hasSortControl ? ( {children} diff --git a/packages/propel/src/components/table/table.tsx b/packages/propel/src/components/table/table.tsx index 86768a19..2e50ccd9 100644 --- a/packages/propel/src/components/table/table.tsx +++ b/packages/propel/src/components/table/table.tsx @@ -8,7 +8,7 @@ import { type TableVariant, TableScrollAreaViewport, } from "../../ui/table"; -import { TableVariantContext } from "../../ui/table/table-context"; +import { TableVariantContext } from "./table-context"; export type TableProps = TableRootProps & { /** Layout (required). `table` draws row dividers only; `spreadsheet` draws a full grid. */ diff --git a/packages/propel/src/ui/table/index.tsx b/packages/propel/src/ui/table/index.tsx index 45351136..1091fa79 100644 --- a/packages/propel/src/ui/table/index.tsx +++ b/packages/propel/src/ui/table/index.tsx @@ -28,4 +28,3 @@ export { TableHeadSortTrigger, type TableHeadSortTriggerProps } from "./table-he export { TableHeadTitle, type TableHeadTitleProps } from "./table-head-title"; export { TableHeader, type TableHeaderProps } from "./table-header"; export { TableRow, type TableRowProps } from "./table-row"; -export { useTableVariant } from "./table-context"; diff --git a/packages/propel/src/ui/table/table-cell.tsx b/packages/propel/src/ui/table/table-cell.tsx index 1f64e58e..52d77793 100644 --- a/packages/propel/src/ui/table/table-cell.tsx +++ b/packages/propel/src/ui/table/table-cell.tsx @@ -1,12 +1,13 @@ import { mergeProps } from "@base-ui/react/merge-props"; import { useRender } from "@base-ui/react/use-render"; -import { TablePinned, useTableVariant } from "./table-context"; -import { tableCellVariants } from "./variants"; +import { type TablePinned, type TableVariant, tableCellVariants } from "./variants"; export type TableCellPadding = "cell" | "trigger"; export type TableCellProps = Omit, "className" | "style"> & { + /** The surrounding table's look, matching the `Table` root. */ + variant: TableVariant; /** Pin this cell to the inline-start/end edge when the table scrolls sideways. */ pinned?: TablePinned; /** @@ -16,11 +17,10 @@ export type TableCellProps = Omit, "className" | padding?: TableCellPadding; }; -/** A data cell (`
    `). Borders follow the surrounding `Table`'s variant. */ -export function TableCell({ pinned, padding = "cell", render, ...props }: TableCellProps) { - const surface = useTableVariant(); +/** A data cell (``). Borders follow the `variant`. */ +export function TableCell({ variant, pinned, padding = "cell", render, ...props }: TableCellProps) { const defaultProps: useRender.ElementProps<"td"> = { - className: tableCellVariants({ surface, pinned: pinned ?? "none", padding }), + className: tableCellVariants({ surface: variant, pinned: pinned ?? "none", padding }), }; return useRender({ defaultTagName: "td", render, props: mergeProps(defaultProps, props) }); } diff --git a/packages/propel/src/ui/table/table-context.tsx b/packages/propel/src/ui/table/table-context.tsx deleted file mode 100644 index 68731790..00000000 --- a/packages/propel/src/ui/table/table-context.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import * as React from "react"; - -/** The two table looks: `table` (row dividers only) and `spreadsheet` (full grid). */ -export type TableVariant = "table" | "spreadsheet"; - -/** Which inline edge a header/cell pins to while the table scrolls sideways. */ -export type TablePinned = "start" | "end"; - -/** - * Carries the root `Table`'s `variant` down to each `TableHead`/`TableCell` so they pick the - * matching borders without the caller repeating the look on every cell. - */ -export const TableVariantContext = React.createContext("table"); - -/** Reads the surrounding `Table`'s `variant`. */ -export function useTableVariant(): TableVariant { - return React.useContext(TableVariantContext); -} diff --git a/packages/propel/src/ui/table/table-head.tsx b/packages/propel/src/ui/table/table-head.tsx index 8b6fe8fb..7a7d5d5a 100644 --- a/packages/propel/src/ui/table/table-head.tsx +++ b/packages/propel/src/ui/table/table-head.tsx @@ -1,23 +1,23 @@ import { mergeProps } from "@base-ui/react/merge-props"; import { useRender } from "@base-ui/react/use-render"; -import { TablePinned, useTableVariant } from "./table-context"; -import { tableHeadVariants } from "./variants"; +import { type TablePinned, type TableVariant, tableHeadVariants } from "./variants"; export type TableHeadProps = Omit, "className" | "style"> & { + /** The surrounding table's look, matching the `Table` root. */ + variant: TableVariant; /** Pin this header to the inline-start/end edge when the table scrolls sideways. */ pinned?: TablePinned; }; /** - * A header cell (``). Borders follow the surrounding `Table`'s variant. Holds a - * `TableHeadTitle` (or, when sortable, a `TableHeadSortTrigger`). + * A header cell (``). Borders follow the `variant`. Holds a `TableHeadTitle` (or, + * when sortable, a `TableHeadSortTrigger`). */ -export function TableHead({ pinned, render, ...props }: TableHeadProps) { - const surface = useTableVariant(); +export function TableHead({ variant, pinned, render, ...props }: TableHeadProps) { const defaultProps: useRender.ElementProps<"th"> = { scope: "col", - className: tableHeadVariants({ surface, pinned: pinned ?? "none" }), + className: tableHeadVariants({ surface: variant, pinned: pinned ?? "none" }), }; return useRender({ defaultTagName: "th", render, props: mergeProps(defaultProps, props) }); } diff --git a/packages/propel/src/ui/table/table.stories.tsx b/packages/propel/src/ui/table/table.stories.tsx index 24b77343..5df8fb5e 100644 --- a/packages/propel/src/ui/table/table.stories.tsx +++ b/packages/propel/src/ui/table/table.stories.tsx @@ -18,7 +18,6 @@ import { TableHeadTitle, TableRow, } from "./index"; -import { TableVariantContext } from "./table-context"; // UI-tier story: composes the ATOMIC table parts (each renders a single table element). // The components-tier `Table` story shows the ready-made `TableHead`/`TableCell` (which compose @@ -59,50 +58,48 @@ const COLUMNS = ["Name", "Display name", "Email", "Account type"]; /** The standard `table` variant: rounded outer border with row dividers only. */ export const Default: Story = { render: () => ( - - - - - {COLUMNS.map((c) => ( - - {c} - - ))} - - - - {PEOPLE.map((person) => ( - - - - - - {person.name.charAt(0)} - - - {person.name} - - - - - {person.display} - - - - - {person.email} - - - - - {person.role} - - - +
    + + + {COLUMNS.map((c) => ( + + {c} + ))} - -
    -
    + + + + {PEOPLE.map((person) => ( + + + + + + {person.name.charAt(0)} + + + {person.name} + + + + + {person.display} + + + + + {person.email} + + + + + {person.role} + + + + ))} + +
    ), play: async ({ canvas }) => { await expect(canvas.getAllByRole("columnheader")).toHaveLength(4); @@ -114,45 +111,43 @@ export const Default: Story = { /** The denser `spreadsheet` variant: every cell is fully bordered to form a grid. */ export const Spreadsheet: Story = { render: () => ( - - - - - {COLUMNS.map((c) => ( - - {c} - - ))} - - - - {PEOPLE.map((person) => ( - - - - {person.name} - - - - - {person.display} - - - - - {person.email} - - - - - {person.role} - - - +
    + + + {COLUMNS.map((c) => ( + + {c} + ))} - -
    -
    + + + + {PEOPLE.map((person) => ( + + + + {person.name} + + + + + {person.display} + + + + + {person.email} + + + + + {person.role} + + + + ))} + + ), }; @@ -171,41 +166,39 @@ export const Sortable: Story = { const cycle = () => setSort((s) => (s === "none" ? "asc" : s === "asc" ? "desc" : "none")); const SortGlyph = sortGlyph[sort]; return ( - - - - - - - Name - - - - - - - Email - +
    + + + + + Name + + + + + + + Email + + + + + {PEOPLE.map((person) => ( + + + + {person.name} + + + + + {person.email} + + - - - {PEOPLE.map((person) => ( - - - - {person.name} - - - - - {person.email} - - - - ))} - -
    -
    + ))} + + ); }, play: async ({ canvas }) => { @@ -227,57 +220,55 @@ export const PinnedColumn: Story = { parameters: { controls: { disable: true } }, render: () => (
    - - - - - - Name - - - Display name - - - Email - - - Account type - +
    + + + + Name + + + Display name + + + Email + + + Account type + + + + + {PEOPLE.map((person) => ( + + + + + + {person.name.charAt(0)} + + + {person.name} + + + + + {person.display} + + + + + {person.email} + + + + + {person.role} + + - - - {PEOPLE.map((person) => ( - - - - - - {person.name.charAt(0)} - - - {person.name} - - - - - {person.display} - - - - - {person.email} - - - - - {person.role} - - - - ))} - -
    -
    + ))} + +
    ), }; diff --git a/packages/propel/src/ui/table/table.tsx b/packages/propel/src/ui/table/table.tsx index c624dbbf..3f94da55 100644 --- a/packages/propel/src/ui/table/table.tsx +++ b/packages/propel/src/ui/table/table.tsx @@ -3,7 +3,7 @@ import { useRender } from "@base-ui/react/use-render"; import { tableVariants } from "./variants"; -export type { TablePinned, TableVariant } from "./table-context"; +export type { TablePinned, TableVariant } from "./variants"; export type TableProps = Omit, "className" | "style">; diff --git a/packages/propel/src/ui/table/variants.ts b/packages/propel/src/ui/table/variants.ts index 037b7b6a..0c682d3e 100644 --- a/packages/propel/src/ui/table/variants.ts +++ b/packages/propel/src/ui/table/variants.ts @@ -1,3 +1,9 @@ +/** The two table looks: `table` (row dividers only) and `spreadsheet` (full grid). */ +export type TableVariant = "table" | "spreadsheet"; + +/** Which inline edge a header/cell pins to while the table scrolls sideways. */ +export type TablePinned = "start" | "end"; + import { cva, cx } from "class-variance-authority"; import { nodeSlotClass } from "../../internal/node-slot"; From e3ee7656d84cb8abb2337984c54bd70cef97dffa Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Wed, 24 Jun 2026 20:28:04 +0700 Subject: [PATCH 11/86] toggle: move ToggleGroupContext to components 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. --- .../components/toggle-group/toggle-group.tsx | 2 +- .../propel/src/components/toggle/index.tsx | 5 +- .../components/toggle/toggle-group-context.ts | 10 +++ .../propel/src/components/toggle/toggle.tsx | 25 ++++++++ .../ui/toggle-group/toggle-group.stories.tsx | 63 +++++++++---------- packages/propel/src/ui/toggle/index.tsx | 1 - .../src/ui/toggle/toggle-group-context.ts | 10 --- packages/propel/src/ui/toggle/toggle.tsx | 19 ++---- 8 files changed, 71 insertions(+), 64 deletions(-) create mode 100644 packages/propel/src/components/toggle/toggle-group-context.ts create mode 100644 packages/propel/src/components/toggle/toggle.tsx delete mode 100644 packages/propel/src/ui/toggle/toggle-group-context.ts diff --git a/packages/propel/src/components/toggle-group/toggle-group.tsx b/packages/propel/src/components/toggle-group/toggle-group.tsx index e0c9326c..d0a01f36 100644 --- a/packages/propel/src/components/toggle-group/toggle-group.tsx +++ b/packages/propel/src/components/toggle-group/toggle-group.tsx @@ -4,8 +4,8 @@ import { ToggleGroup as ToggleGroupRoot, type ToggleGroupProps as ToggleGroupRootProps, } from "../../ui/toggle-group"; -import { ToggleGroupContext } from "../../ui/toggle/toggle-group-context"; import type { ToggleMagnitude } from "../../ui/toggle/variants"; +import { ToggleGroupContext } from "../toggle/toggle-group-context"; export type ToggleGroupProps = ToggleGroupRootProps & { /** Size applied to every `Toggle` in the group (each `Toggle` can still override it). */ diff --git a/packages/propel/src/components/toggle/index.tsx b/packages/propel/src/components/toggle/index.tsx index b73dd0f7..5f397ad5 100644 --- a/packages/propel/src/components/toggle/index.tsx +++ b/packages/propel/src/components/toggle/index.tsx @@ -1,3 +1,2 @@ -// Ready-made 1:1 re-export of the ui primitive. Drop down to `@plane/propel/ui/toggle` only -// when you need the lower-level parts. -export * from "../../ui/toggle"; +export { Toggle, type ToggleProps } from "./toggle"; +export { ToggleIcon, type ToggleIconProps, type ToggleMagnitude } from "../../ui/toggle"; diff --git a/packages/propel/src/components/toggle/toggle-group-context.ts b/packages/propel/src/components/toggle/toggle-group-context.ts new file mode 100644 index 00000000..51de6f02 --- /dev/null +++ b/packages/propel/src/components/toggle/toggle-group-context.ts @@ -0,0 +1,10 @@ +import * as React from "react"; + +import { type ToggleMagnitude } from "../../ui/toggle"; + +/** + * Set by the components `ToggleGroup` so every `Toggle` inside it shares one `magnitude`. Lives in + * the components tier: a context is cross-tree coordination — composition — not a single-element + * `ui` concern. + */ +export const ToggleGroupContext = React.createContext(undefined); diff --git a/packages/propel/src/components/toggle/toggle.tsx b/packages/propel/src/components/toggle/toggle.tsx new file mode 100644 index 00000000..2634ac64 --- /dev/null +++ b/packages/propel/src/components/toggle/toggle.tsx @@ -0,0 +1,25 @@ +import * as React from "react"; + +import { + Toggle as ToggleRoot, + type ToggleProps as ToggleRootProps, + type ToggleMagnitude, +} from "../../ui/toggle"; +import { ToggleGroupContext } from "./toggle-group-context"; + +export type ToggleProps = Omit< + ToggleRootProps, + "magnitude" +> & { + /** + * Size override. Inside a `ToggleGroup` the group's `magnitude` is used (so you can omit it); + * standalone it defaults to `md`. + */ + magnitude?: ToggleMagnitude; +}; + +/** The ready-made toggle: takes its `magnitude` from the surrounding `ToggleGroup` via context. */ +export function Toggle({ magnitude, ...props }: ToggleProps) { + const groupMagnitude = React.useContext(ToggleGroupContext); + return ; +} diff --git a/packages/propel/src/ui/toggle-group/toggle-group.stories.tsx b/packages/propel/src/ui/toggle-group/toggle-group.stories.tsx index a151f869..be6fb9b0 100644 --- a/packages/propel/src/ui/toggle-group/toggle-group.stories.tsx +++ b/packages/propel/src/ui/toggle-group/toggle-group.stories.tsx @@ -3,7 +3,6 @@ import { AlignCenter, AlignLeft, AlignRight } from "lucide-react"; import { expect, fn } from "storybook/test"; import { Toggle, ToggleIcon } from "../toggle/index"; -import { ToggleGroupContext } from "../toggle/toggle-group-context"; import { ToggleGroup } from "./index"; // UI-tier story: the atomic `ToggleGroup` (single/multi-select state + roving focus) composed with @@ -30,25 +29,23 @@ type Story = StoryObj; export const Default: Story = { args: { defaultValue: ["left"], onValueChange: fn() }, render: (args) => ( - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + ), play: async ({ canvas, userEvent, args }) => { const left = canvas.getByRole("button", { name: "Align left" }); @@ -67,20 +64,18 @@ export const Multiple: Story = { tags: ["!dev", "!autodocs", "!manifest"], args: { multiple: true, defaultValue: [] }, render: (args) => ( - - - - - - - - - - - - - - + + + + + + + + + + + + ), play: async ({ canvas, userEvent }) => { const bold = canvas.getByRole("button", { name: "Bold" }); diff --git a/packages/propel/src/ui/toggle/index.tsx b/packages/propel/src/ui/toggle/index.tsx index 55802587..8b64eead 100644 --- a/packages/propel/src/ui/toggle/index.tsx +++ b/packages/propel/src/ui/toggle/index.tsx @@ -1,4 +1,3 @@ export { Toggle, type ToggleProps } from "./toggle"; export { ToggleIcon, type ToggleIconProps } from "./toggle-icon"; -export { ToggleGroupContext } from "./toggle-group-context"; export { type ToggleMagnitude } from "./variants"; diff --git a/packages/propel/src/ui/toggle/toggle-group-context.ts b/packages/propel/src/ui/toggle/toggle-group-context.ts deleted file mode 100644 index aa49b7d3..00000000 --- a/packages/propel/src/ui/toggle/toggle-group-context.ts +++ /dev/null @@ -1,10 +0,0 @@ -import * as React from "react"; - -import type { ToggleMagnitude } from "./variants"; - -/** - * Set by `ToggleGroup` so every `Toggle` inside it shares one `magnitude`, keeping a group - * consistently sized. A `Toggle`'s own `magnitude` prop takes precedence. Lives with `Toggle` - * because `Toggle` is the consumer; `ToggleGroup` provides it. - */ -export const ToggleGroupContext = React.createContext(undefined); diff --git a/packages/propel/src/ui/toggle/toggle.tsx b/packages/propel/src/ui/toggle/toggle.tsx index 6802ccb4..678b33fa 100644 --- a/packages/propel/src/ui/toggle/toggle.tsx +++ b/packages/propel/src/ui/toggle/toggle.tsx @@ -1,32 +1,21 @@ import { Toggle as BaseToggle } from "@base-ui/react/toggle"; import type { Toggle as BaseToggleTypes } from "@base-ui/react/toggle"; -import * as React from "react"; -import { ToggleGroupContext } from "./toggle-group-context"; import { type ToggleMagnitude, toggleVariants } from "./variants"; export type ToggleProps = Omit< BaseToggleTypes.Props, "className" | "style" > & { - /** - * Button size. Required for standalone usage. When inside a `ToggleGroup`, omit it and let the - * group provide a consistent magnitude via context; pass it explicitly to override the group's - * choice. - */ - magnitude?: ToggleMagnitude; + /** Button size (required). Inside the components `ToggleGroup` the group's magnitude is applied. */ + magnitude: ToggleMagnitude; }; /** * A two-state button (pressed / not pressed) — typically wrapping an icon. Built on Base UI's - * `Toggle`, so pressed/disabled are control state (`pressed`/`defaultPressed`, `disabled`), - * reflected as `[data-pressed]`/`[data-disabled]`. Only `magnitude` is a visual axis. Maps 1:1 to + * `Toggle`; pressed/disabled are control state. Only `magnitude` is a visual axis. Maps 1:1 to * `Toggle`. */ export function Toggle({ magnitude, ...props }: ToggleProps) { - // An explicit `magnitude` wins; otherwise inherit the group's (if inside one). Standalone - // Toggles must always pass `magnitude` — there is no silent fallback. - const groupMagnitude = React.useContext(ToggleGroupContext); - const effectiveMagnitude = magnitude ?? groupMagnitude; - return ; + return ; } From 751083fefc5442ed0e426846820413326ab8d69f Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Wed, 24 Jun 2026 20:34:46 +0700 Subject: [PATCH 12/86] toolbar: move ToolbarDensityContext to components 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. --- .../propel/src/components/toolbar/index.tsx | 22 +- .../src/components/toolbar/toolbar-button.tsx | 19 ++ .../src/components/toolbar/toolbar-context.ts | 10 + .../toolbar/toolbar-menu-trigger-button.tsx | 19 ++ .../toolbar/toolbar-menu-trigger.tsx | 7 +- .../src/components/toolbar/toolbar-toggle.tsx | 25 +++ .../propel/src/components/toolbar/toolbar.tsx | 2 +- .../propel/src/patterns/comment.stories.tsx | 6 +- packages/propel/src/ui/toolbar/index.tsx | 6 +- .../propel/src/ui/toolbar/toolbar-button.tsx | 9 +- .../propel/src/ui/toolbar/toolbar-context.ts | 9 - .../toolbar/toolbar-menu-trigger-button.tsx | 15 +- .../propel/src/ui/toolbar/toolbar-toggle.tsx | 10 +- .../propel/src/ui/toolbar/toolbar.stories.tsx | 200 +++++++++--------- packages/propel/src/ui/toolbar/toolbar.tsx | 4 +- packages/propel/src/ui/toolbar/variants.ts | 8 +- 16 files changed, 213 insertions(+), 158 deletions(-) create mode 100644 packages/propel/src/components/toolbar/toolbar-button.tsx create mode 100644 packages/propel/src/components/toolbar/toolbar-context.ts create mode 100644 packages/propel/src/components/toolbar/toolbar-menu-trigger-button.tsx create mode 100644 packages/propel/src/components/toolbar/toolbar-toggle.tsx delete mode 100644 packages/propel/src/ui/toolbar/toolbar-context.ts diff --git a/packages/propel/src/components/toolbar/index.tsx b/packages/propel/src/components/toolbar/index.tsx index 50794dd0..8ec5a7bc 100644 --- a/packages/propel/src/components/toolbar/index.tsx +++ b/packages/propel/src/components/toolbar/index.tsx @@ -1,24 +1,24 @@ +export { ToolbarButton, type ToolbarButtonProps } from "./toolbar-button"; +export { ToolbarToggle, type ToolbarToggleProps } from "./toolbar-toggle"; export { - ToolbarButton, ToolbarMenuTriggerButton, + type ToolbarMenuTriggerButtonProps, +} from "./toolbar-menu-trigger-button"; +export { ToolbarMenuTriggerIndicator, + type ToolbarMenuTriggerIndicatorProps, ToolbarMenuTriggerLabel, + type ToolbarMenuTriggerLabelProps, ToolbarGroup, + type ToolbarGroupProps, ToolbarItemIcon, + type ToolbarItemIconProps, ToolbarSeparator, - ToolbarToggle, + type ToolbarSeparatorProps, ToolbarToggleGroup, - type ToolbarButtonProps, + type ToolbarToggleGroupProps, type ToolbarDensity, - type ToolbarMenuTriggerButtonProps, - type ToolbarMenuTriggerIndicatorProps, - type ToolbarMenuTriggerLabelProps, type ToolbarElevation, - type ToolbarGroupProps, - type ToolbarItemIconProps, - type ToolbarSeparatorProps, - type ToolbarToggleGroupProps, - type ToolbarToggleProps, } from "../../ui/toolbar/index"; export { Toolbar, type ToolbarProps } from "./toolbar"; export { ToolbarMenu, type ToolbarMenuProps } from "./toolbar-menu"; diff --git a/packages/propel/src/components/toolbar/toolbar-button.tsx b/packages/propel/src/components/toolbar/toolbar-button.tsx new file mode 100644 index 00000000..e9c1e66a --- /dev/null +++ b/packages/propel/src/components/toolbar/toolbar-button.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; + +import { + ToolbarButton as ToolbarButtonRoot, + type ToolbarButtonProps as ToolbarButtonRootProps, + type ToolbarDensity, +} from "../../ui/toolbar"; +import { ToolbarDensityContext } from "./toolbar-context"; + +export type ToolbarButtonProps = Omit & { + /** Density override; defaults to the surrounding `Toolbar`'s density. */ + density?: ToolbarDensity; +}; + +/** A toolbar action button that takes its `density` from the surrounding `Toolbar` via context. */ +export function ToolbarButton({ density, ...props }: ToolbarButtonProps) { + const toolbarDensity = React.useContext(ToolbarDensityContext); + return ; +} diff --git a/packages/propel/src/components/toolbar/toolbar-context.ts b/packages/propel/src/components/toolbar/toolbar-context.ts new file mode 100644 index 00000000..1a117dcf --- /dev/null +++ b/packages/propel/src/components/toolbar/toolbar-context.ts @@ -0,0 +1,10 @@ +import * as React from "react"; + +import { type ToolbarDensity } from "../../ui/toolbar"; + +/** + * Set by the components `Toolbar` so the controls inside share its `density`. Lives in the + * components tier: a context is cross-tree coordination — composition — not a single-element `ui` + * concern. + */ +export const ToolbarDensityContext = React.createContext("compact"); diff --git a/packages/propel/src/components/toolbar/toolbar-menu-trigger-button.tsx b/packages/propel/src/components/toolbar/toolbar-menu-trigger-button.tsx new file mode 100644 index 00000000..790bc6c2 --- /dev/null +++ b/packages/propel/src/components/toolbar/toolbar-menu-trigger-button.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; + +import { + ToolbarMenuTriggerButton as ToolbarMenuTriggerButtonRoot, + type ToolbarMenuTriggerButtonProps as ToolbarMenuTriggerButtonRootProps, + type ToolbarDensity, +} from "../../ui/toolbar"; +import { ToolbarDensityContext } from "./toolbar-context"; + +export type ToolbarMenuTriggerButtonProps = Omit & { + /** Density override; defaults to the surrounding `Toolbar`'s density. */ + density?: ToolbarDensity; +}; + +/** A toolbar menu-trigger button that takes its `density` from the surrounding `Toolbar`. */ +export function ToolbarMenuTriggerButton({ density, ...props }: ToolbarMenuTriggerButtonProps) { + const toolbarDensity = React.useContext(ToolbarDensityContext); + return ; +} diff --git a/packages/propel/src/components/toolbar/toolbar-menu-trigger.tsx b/packages/propel/src/components/toolbar/toolbar-menu-trigger.tsx index ebcfed4d..5eb21599 100644 --- a/packages/propel/src/components/toolbar/toolbar-menu-trigger.tsx +++ b/packages/propel/src/components/toolbar/toolbar-menu-trigger.tsx @@ -1,11 +1,8 @@ import { Menu } from "@base-ui/react/menu"; import { ChevronDown } from "lucide-react"; -import { - ToolbarMenuTriggerButton, - ToolbarMenuTriggerIndicator, - ToolbarMenuTriggerLabel, -} from "../../ui/toolbar/index"; +import { ToolbarMenuTriggerIndicator, ToolbarMenuTriggerLabel } from "../../ui/toolbar/index"; +import { ToolbarMenuTriggerButton } from "./toolbar-menu-trigger-button"; export type ToolbarMenuTriggerProps = Omit; diff --git a/packages/propel/src/components/toolbar/toolbar-toggle.tsx b/packages/propel/src/components/toolbar/toolbar-toggle.tsx new file mode 100644 index 00000000..9dddfced --- /dev/null +++ b/packages/propel/src/components/toolbar/toolbar-toggle.tsx @@ -0,0 +1,25 @@ +import * as React from "react"; + +import { + ToolbarToggle as ToolbarToggleRoot, + type ToolbarToggleProps as ToolbarToggleRootProps, + type ToolbarDensity, +} from "../../ui/toolbar"; +import { ToolbarDensityContext } from "./toolbar-context"; + +export type ToolbarToggleProps = Omit< + ToolbarToggleRootProps, + "density" +> & { + /** Density override; defaults to the surrounding `Toolbar`'s density. */ + density?: ToolbarDensity; +}; + +/** A toolbar formatting toggle that takes its `density` from the surrounding `Toolbar` via context. */ +export function ToolbarToggle({ + density, + ...props +}: ToolbarToggleProps) { + const toolbarDensity = React.useContext(ToolbarDensityContext); + return ; +} diff --git a/packages/propel/src/components/toolbar/toolbar.tsx b/packages/propel/src/components/toolbar/toolbar.tsx index 20ed51db..2a8e5d23 100644 --- a/packages/propel/src/components/toolbar/toolbar.tsx +++ b/packages/propel/src/components/toolbar/toolbar.tsx @@ -1,7 +1,7 @@ import type * as React from "react"; import { Toolbar as ToolbarRoot, type ToolbarProps as ToolbarRootProps } from "../../ui/toolbar"; -import { ToolbarDensityContext } from "../../ui/toolbar/toolbar-context"; +import { ToolbarDensityContext } from "./toolbar-context"; export type ToolbarProps = ToolbarRootProps & { children?: React.ReactNode; diff --git a/packages/propel/src/patterns/comment.stories.tsx b/packages/propel/src/patterns/comment.stories.tsx index bad8b9e0..e8ab6c28 100644 --- a/packages/propel/src/patterns/comment.stories.tsx +++ b/packages/propel/src/patterns/comment.stories.tsx @@ -16,15 +16,15 @@ import * as React from "react"; import { expect, fn, userEvent } from "storybook/test"; import { IconButton } from "../components/icon-button/index"; -import { Button } from "../ui/button/index"; -import { Field, TextAreaFieldControl } from "../ui/field/index"; import { Toolbar, ToolbarButton, ToolbarGroup, ToolbarSeparator, ToolbarToggle, -} from "../ui/toolbar/index"; +} from "../components/toolbar/index"; +import { Button } from "../ui/button/index"; +import { Field, TextAreaFieldControl } from "../ui/field/index"; // A comment composer is a compositional (application-level) component, not a propel // primitive: it is assembled entirely from propel building blocks (Toolbar, Button, diff --git a/packages/propel/src/ui/toolbar/index.tsx b/packages/propel/src/ui/toolbar/index.tsx index c71a7057..2be4e1ab 100644 --- a/packages/propel/src/ui/toolbar/index.tsx +++ b/packages/propel/src/ui/toolbar/index.tsx @@ -17,8 +17,4 @@ export { ToolbarItemIcon, type ToolbarItemIconProps } from "./toolbar-item-icon" export { ToolbarSeparator, type ToolbarSeparatorProps } from "./toolbar-separator"; export { ToolbarToggle, type ToolbarToggleProps } from "./toolbar-toggle"; export { ToolbarToggleGroup, type ToolbarToggleGroupProps } from "./toolbar-toggle-group"; -export { - ToolbarDensityContext, - type ToolbarDensity, - type ToolbarElevation, -} from "./toolbar-context"; +export { type ToolbarDensity, type ToolbarElevation } from "./variants"; diff --git a/packages/propel/src/ui/toolbar/toolbar-button.tsx b/packages/propel/src/ui/toolbar/toolbar-button.tsx index e0a645ee..ef9d76cd 100644 --- a/packages/propel/src/ui/toolbar/toolbar-button.tsx +++ b/packages/propel/src/ui/toolbar/toolbar-button.tsx @@ -1,16 +1,15 @@ import { Toolbar as BaseToolbar } from "@base-ui/react/toolbar"; -import * as React from "react"; -import { ToolbarDensityContext } from "./toolbar-context"; -import { toolbarItemVariants } from "./variants"; +import { type ToolbarDensity, toolbarItemVariants } from "./variants"; export type ToolbarButtonProps = Omit & { /** Accessible name for the icon button. */ "aria-label": string; + /** Control sizing, matching the toolbar's density. */ + density: ToolbarDensity; }; /** A plain action button in the toolbar. */ -export function ToolbarButton(props: ToolbarButtonProps) { - const density = React.useContext(ToolbarDensityContext); +export function ToolbarButton({ density, ...props }: ToolbarButtonProps) { return ; } diff --git a/packages/propel/src/ui/toolbar/toolbar-context.ts b/packages/propel/src/ui/toolbar/toolbar-context.ts deleted file mode 100644 index 48fe1773..00000000 --- a/packages/propel/src/ui/toolbar/toolbar-context.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { type VariantProps } from "class-variance-authority"; -import * as React from "react"; - -import { toolbarVariants } from "./variants"; - -export type ToolbarElevation = NonNullable["elevation"]>; -export type ToolbarDensity = NonNullable["density"]>; - -export const ToolbarDensityContext = React.createContext("compact"); diff --git a/packages/propel/src/ui/toolbar/toolbar-menu-trigger-button.tsx b/packages/propel/src/ui/toolbar/toolbar-menu-trigger-button.tsx index 4d27edb9..20e6e7ed 100644 --- a/packages/propel/src/ui/toolbar/toolbar-menu-trigger-button.tsx +++ b/packages/propel/src/ui/toolbar/toolbar-menu-trigger-button.tsx @@ -1,14 +1,17 @@ import { Toolbar as BaseToolbar } from "@base-ui/react/toolbar"; -import * as React from "react"; -import { ToolbarDensityContext } from "./toolbar-context"; -import { toolbarMenuTriggerButtonVariants } from "./variants"; +import { type ToolbarDensity, toolbarMenuTriggerButtonVariants } from "./variants"; -export type ToolbarMenuTriggerButtonProps = Omit; +export type ToolbarMenuTriggerButtonProps = Omit< + BaseToolbar.Button.Props, + "className" | "style" +> & { + /** Control sizing, matching the toolbar's density. */ + density: ToolbarDensity; +}; /** The styled chrome for a toolbar menu trigger: a text label slot with density-aware sizing. */ -export function ToolbarMenuTriggerButton(props: ToolbarMenuTriggerButtonProps) { - const density = React.useContext(ToolbarDensityContext); +export function ToolbarMenuTriggerButton({ density, ...props }: ToolbarMenuTriggerButtonProps) { return ( ); diff --git a/packages/propel/src/ui/toolbar/toolbar-toggle.tsx b/packages/propel/src/ui/toolbar/toolbar-toggle.tsx index 43bb6867..5447abd5 100644 --- a/packages/propel/src/ui/toolbar/toolbar-toggle.tsx +++ b/packages/propel/src/ui/toolbar/toolbar-toggle.tsx @@ -1,10 +1,8 @@ import { Toggle } from "@base-ui/react/toggle"; import type { Toggle as BaseToggleTypes } from "@base-ui/react/toggle"; import { Toolbar as BaseToolbar } from "@base-ui/react/toolbar"; -import * as React from "react"; -import { ToolbarDensityContext } from "./toolbar-context"; -import { toolbarItemVariants } from "./variants"; +import { type ToolbarDensity, toolbarItemVariants } from "./variants"; export type ToolbarToggleProps = Omit< BaseToggleTypes.Props, @@ -12,16 +10,16 @@ export type ToolbarToggleProps = Omit< > & { /** Accessible name for the icon toggle. */ "aria-label": string; + /** Control sizing, matching the toolbar's density. */ + density: ToolbarDensity; }; /** A two-state button for formatting toggles like bold or italic. */ export function ToolbarToggle({ render, + density, ...props }: ToolbarToggleProps) { - const density = React.useContext(ToolbarDensityContext); - // The toolbar item renders as a `Toggle`; a consumer `render` customizes that inner - // element (not the wrapping `Toolbar.Button`), so it is nested onto the `Toggle`. return ( } diff --git a/packages/propel/src/ui/toolbar/toolbar.stories.tsx b/packages/propel/src/ui/toolbar/toolbar.stories.tsx index 1f20c12e..b2c810c3 100644 --- a/packages/propel/src/ui/toolbar/toolbar.stories.tsx +++ b/packages/propel/src/ui/toolbar/toolbar.stories.tsx @@ -25,12 +25,10 @@ import { ToolbarToggle, ToolbarToggleGroup, } from "./index"; -import { ToolbarDensityContext } from "./toolbar-context"; -// UI-tier story: composes the ATOMIC toolbar parts (each renders a single element). `Toolbar` is a -// single element; its `density` styles the row, and is wired to the controls inside via the -// `ToolbarDensityContext` provider explicitly here (the components-tier `Toolbar` does this for you -// via its ready-made). Every control's icon is its own `ToolbarItemIcon` slot. +// UI-tier story: composes the ATOMIC toolbar parts (each a single, prop-driven element). `Toolbar` +// and each control take `density` explicitly — the ready-made `components/toolbar` sets it once and +// shares it via context (so you don't repeat it). Every control's icon is its own `ToolbarItemIcon`. const meta = { title: "UI/Toolbar", component: Toolbar, @@ -58,68 +56,66 @@ type Story = StoryObj; */ export const Default: Story = { render: (args) => ( - - - - Text - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + Text + + + + + + + + + + + + + + + + + - + - - + + - + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ), play: async ({ canvas, userEvent }) => { // The root carries the toolbar role, and a toggle flips its pressed state on click. @@ -140,26 +136,24 @@ export const Elevations: Story = { render: (args) => (
    {(["raised", "flat"] as const).map((elevation) => ( - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + ))}
    ), @@ -167,33 +161,31 @@ export const Elevations: Story = { /** * The `density` axis: `compact` packs the controls to 24px hit targets, `comfortable` gives them - * 28px. `density` drives the child controls' size through context. + * 28px. */ export const Densities: Story = { argTypes: { density: { control: false } }, render: (args) => (
    {(["compact", "comfortable"] as const).map((density) => ( - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + ))}
    ), diff --git a/packages/propel/src/ui/toolbar/toolbar.tsx b/packages/propel/src/ui/toolbar/toolbar.tsx index 6d131111..ef4d0838 100644 --- a/packages/propel/src/ui/toolbar/toolbar.tsx +++ b/packages/propel/src/ui/toolbar/toolbar.tsx @@ -1,9 +1,9 @@ import { Toolbar as BaseToolbar } from "@base-ui/react/toolbar"; -import { type ToolbarDensity, type ToolbarElevation } from "./toolbar-context"; +import { type ToolbarDensity, type ToolbarElevation } from "./variants"; import { toolbarVariants } from "./variants"; -export type { ToolbarDensity, ToolbarElevation } from "./toolbar-context"; +export type { ToolbarDensity, ToolbarElevation } from "./variants"; export type ToolbarProps = Omit & { /** Whether the toolbar draws its own surface. */ diff --git a/packages/propel/src/ui/toolbar/variants.ts b/packages/propel/src/ui/toolbar/variants.ts index f326397b..a2f412c5 100644 --- a/packages/propel/src/ui/toolbar/variants.ts +++ b/packages/propel/src/ui/toolbar/variants.ts @@ -1,4 +1,4 @@ -import { cva, cx } from "class-variance-authority"; +import { cva, cx, type VariantProps } from "class-variance-authority"; import { nodeSlotClass } from "../../internal/node-slot"; import { surfaceVariants } from "../../internal/surface"; @@ -78,3 +78,9 @@ export const toolbarMenuTriggerLabelVariants = cva("text-13"); // The disclosure caret at the trigger's inline-end. Sizes its single child to the // trigger's `--node-size` and tints it; a slot, like `ToolbarItemIcon`. export const toolbarMenuTriggerIndicatorVariants = cva(cx(nodeSlotClass, "text-icon-secondary")); + +/** Whether the toolbar draws its own surface (`raised`) or sits flush (`flat`). */ +export type ToolbarElevation = NonNullable["elevation"]>; + +/** How tightly the controls pack: `compact` (24px) or `comfortable` (28px). */ +export type ToolbarDensity = NonNullable["density"]>; From 68685b5eee668f6647d5299e934325852ad201e6 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Wed, 24 Jun 2026 20:37:11 +0700 Subject: [PATCH 13/86] Format avatar-group components file (full --fix; scoped run slipped it) --- packages/propel/src/components/avatar-group/avatar-group.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/propel/src/components/avatar-group/avatar-group.tsx b/packages/propel/src/components/avatar-group/avatar-group.tsx index 06c70abd..c0c54ea7 100644 --- a/packages/propel/src/components/avatar-group/avatar-group.tsx +++ b/packages/propel/src/components/avatar-group/avatar-group.tsx @@ -1,11 +1,11 @@ import type * as React from "react"; import { type AvatarMagnitude } from "../../ui/avatar"; -import { AvatarGroupContext } from "../avatar/avatar-group-context"; import { AvatarGroup as AvatarGroupRoot, type AvatarGroupProps as AvatarGroupRootProps, } from "../../ui/avatar-group"; +import { AvatarGroupContext } from "../avatar/avatar-group-context"; // Figma's "Avatar Groups" component only defines three sizes (Small/Base/Large = 16/20/24px), so // groups are limited to the matching magnitudes — narrower than a standalone Avatar's full scale. From e06faa695e6ccd753000e069d9e8666d8fc798c5 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Wed, 24 Jun 2026 20:44:27 +0700 Subject: [PATCH 14/86] avatar: ui Avatar magnitude is required (no default in ui) 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). --- packages/propel/src/components/avatar/avatar.tsx | 5 ++++- packages/propel/src/ui/avatar/avatar.stories.tsx | 3 +++ packages/propel/src/ui/avatar/avatar.tsx | 8 ++++---- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/propel/src/components/avatar/avatar.tsx b/packages/propel/src/components/avatar/avatar.tsx index 5e03a2ce..b6908ec3 100644 --- a/packages/propel/src/components/avatar/avatar.tsx +++ b/packages/propel/src/components/avatar/avatar.tsx @@ -6,13 +6,16 @@ import { AvatarFallback, AvatarIcon, AvatarImage, + type AvatarMagnitude, type AvatarProps as AvatarRootProps, type AvatarTone, getAvatarTone, } from "../../ui/avatar"; import { AvatarGroupContext } from "./avatar-group-context"; -export type AvatarProps = AvatarRootProps & { +export type AvatarProps = Omit & { + /** Avatar size. Optional here — resolved from the `AvatarGroup` context, else `md`. */ + magnitude?: AvatarMagnitude; /** Image URL. When omitted, or while it is loading/failing, the fallback shows. */ src?: string; /** Accessible name for the avatar (the person it represents). */ diff --git a/packages/propel/src/ui/avatar/avatar.stories.tsx b/packages/propel/src/ui/avatar/avatar.stories.tsx index 2870d479..3d12fe84 100644 --- a/packages/propel/src/ui/avatar/avatar.stories.tsx +++ b/packages/propel/src/ui/avatar/avatar.stories.tsx @@ -20,6 +20,9 @@ const meta = { title: "UI/Avatar", component: Avatar, subcomponents: { AvatarImage, AvatarFallback, AvatarIcon }, + // `magnitude` is required on the ui `Avatar`; the per-story renders pass it explicitly, this just + // satisfies the story arg type. + args: { magnitude: "md" }, } satisfies Meta; export default meta; diff --git a/packages/propel/src/ui/avatar/avatar.tsx b/packages/propel/src/ui/avatar/avatar.tsx index 77406b49..91547eb7 100644 --- a/packages/propel/src/ui/avatar/avatar.tsx +++ b/packages/propel/src/ui/avatar/avatar.tsx @@ -28,10 +28,10 @@ export function getAvatarTone(seed: string): AvatarTone { /** Props for {@link Avatar} (the Base UI `Avatar.Root`), plus a `magnitude`. */ export type AvatarProps = Omit & { /** - * Avatar size. Optional — defaults to `md` standalone. Inside the components `AvatarGroup` the - * group's magnitude is applied for you (shared via context from the ready-made). + * Avatar size (required — no silent default). The ready-made `components/avatar` resolves it from + * the `AvatarGroup` context or its own default. */ - magnitude?: AvatarMagnitude; + magnitude: AvatarMagnitude; }; /** @@ -39,6 +39,6 @@ export type AvatarProps = Omit & { * Maps 1:1 to Base UI's `Avatar.Root`. For the ready-made image+initials+icon avatar, use the * `Avatar` from `@plane/propel/components/avatar`. */ -export function Avatar({ magnitude = "md", ...props }: AvatarProps) { +export function Avatar({ magnitude, ...props }: AvatarProps) { return ; } From b04616b5bd8571664505baaffc30e2337046f450 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Wed, 24 Jun 2026 20:51:26 +0700 Subject: [PATCH 15/86] button: derive variant-prop types in variants.ts (XVariantProps convention) 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. --- packages/propel/src/ui/button/button.tsx | 28 +++++++++++++++-------- packages/propel/src/ui/button/variants.ts | 9 +++++++- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/packages/propel/src/ui/button/button.tsx b/packages/propel/src/ui/button/button.tsx index c3deacc0..4fb1af48 100644 --- a/packages/propel/src/ui/button/button.tsx +++ b/packages/propel/src/ui/button/button.tsx @@ -1,21 +1,29 @@ import { Button as BaseButton } from "@base-ui/react/button"; -import { type VariantProps } from "class-variance-authority"; -import { buttonVariants } from "./variants"; +import { + type ButtonEmphasis, + type ButtonMagnitude, + type ButtonStretch, + type ButtonTone, + type ButtonVariant, + buttonVariants, +} from "./variants"; -// Re-exported so `buttonVariants` stays part of the button entry's public surface -// (e.g. `icon-button` composes it). +// Re-exported so `buttonVariants` + its variant-prop types stay part of the button entry's public +// surface (e.g. `icon-button` composes it). export { buttonVariants } from "./variants"; +export type { + ButtonEmphasis, + ButtonMagnitude, + ButtonStretch, + ButtonTone, + ButtonVariant, + ButtonVariantProps, +} from "./variants"; export { ButtonIcon, type ButtonIconProps } from "./button-icon"; export { ButtonLabel, type ButtonLabelProps } from "./button-label"; export { ButtonSpinner, type ButtonSpinnerProps } from "./button-spinner"; -export type ButtonVariant = NonNullable["variant"]>; -export type ButtonTone = NonNullable["tone"]>; -export type ButtonMagnitude = NonNullable["magnitude"]>; -export type ButtonEmphasis = NonNullable["emphasis"]>; -export type ButtonStretch = NonNullable["stretch"]>; - type ButtonOwnProps = { variant: ButtonVariant; tone: ButtonTone; diff --git a/packages/propel/src/ui/button/variants.ts b/packages/propel/src/ui/button/variants.ts index b1e32d5f..a58370e4 100644 --- a/packages/propel/src/ui/button/variants.ts +++ b/packages/propel/src/ui/button/variants.ts @@ -1,4 +1,4 @@ -import { cva, cx } from "class-variance-authority"; +import { cva, cx, type VariantProps } from "class-variance-authority"; import { nodeSlotClass } from "../../internal/node-slot"; @@ -161,6 +161,13 @@ export const buttonVariants = cva( }, ); +export type ButtonVariantProps = VariantProps; +export type ButtonVariant = NonNullable; +export type ButtonTone = NonNullable; +export type ButtonMagnitude = NonNullable; +export type ButtonEmphasis = NonNullable; +export type ButtonStretch = NonNullable; + // The text label inside a Button. When the parent button is `aria-busy` (loading) // it dims via the `group-aria-busy:` sibling of the `group` class on the root: the // spinner replaces the inline-start node, and this fades the text alongside it. From 19cff2c916b016cd7b19368efb1b5c6436e1ece8 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Wed, 24 Jun 2026 21:06:05 +0700 Subject: [PATCH 16/86] Add StrictVariantProps + apply to Button (variant props optional iff defaulted) internal/variant-props.ts: StrictVariantProps 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 & ButtonVariantProps (no hand-built ButtonOwnProps). --- packages/propel/src/internal/variant-props.ts | 27 ++++++++++++++++ packages/propel/src/ui/button/button.tsx | 31 +++---------------- packages/propel/src/ui/button/variants.ts | 26 ++++++++++++---- 3 files changed, 51 insertions(+), 33 deletions(-) create mode 100644 packages/propel/src/internal/variant-props.ts diff --git a/packages/propel/src/internal/variant-props.ts b/packages/propel/src/internal/variant-props.ts new file mode 100644 index 00000000..cb5e2307 --- /dev/null +++ b/packages/propel/src/internal/variant-props.ts @@ -0,0 +1,27 @@ +import type { VariantProps } from "class-variance-authority"; + +// Every axis required and non-null (the cva variant values are ` | null | undefined`). +type NonNullableVariants = { + [Key in keyof Variants]-?: NonNullable; +}; + +/** + * Type-safe variant props for a `cva` helper. + * + * Every variant axis is **required** (and non-null) unless it has a configured default, in which + * case it is **optional**. This keeps the props honest: you can only omit an axis that has a real + * fallback in `defaultVariants`, so you can never leave a styling axis unset with no default — an + * impossible visual state. Pass the union of defaulted keys (typically + * `keyof typeof DefaultVariants`); omit it when the cva has no defaults (all axes required). + * + * @example + * const buttonDefaultVariants = { emphasis: "solid", stretch: "auto" } as const; + * export const buttonVariants = cva(base, { variants, compoundVariants, defaultVariants: buttonDefaultVariants }); + * export type ButtonVariantProps = StrictVariantProps; + * // -> { variant: …; tone: …; magnitude: …; emphasis?: …; stretch?: … } + */ +export type StrictVariantProps< + Component extends (...args: never[]) => unknown, + Defaulted extends keyof VariantProps = never, +> = Omit>, Defaulted> & + Partial>, Defaulted>>; diff --git a/packages/propel/src/ui/button/button.tsx b/packages/propel/src/ui/button/button.tsx index 4fb1af48..eeadd788 100644 --- a/packages/propel/src/ui/button/button.tsx +++ b/packages/propel/src/ui/button/button.tsx @@ -1,13 +1,6 @@ import { Button as BaseButton } from "@base-ui/react/button"; -import { - type ButtonEmphasis, - type ButtonMagnitude, - type ButtonStretch, - type ButtonTone, - type ButtonVariant, - buttonVariants, -} from "./variants"; +import { type ButtonVariantProps, buttonVariants } from "./variants"; // Re-exported so `buttonVariants` + its variant-prop types stay part of the button entry's public // surface (e.g. `icon-button` composes it). @@ -24,25 +17,9 @@ export { ButtonIcon, type ButtonIconProps } from "./button-icon"; export { ButtonLabel, type ButtonLabelProps } from "./button-label"; export { ButtonSpinner, type ButtonSpinnerProps } from "./button-spinner"; -type ButtonOwnProps = { - variant: ButtonVariant; - tone: ButtonTone; - magnitude: ButtonMagnitude; - /** - * Link-only: picks the `link` look. `solid` is the blue `link/primary` style; `subtle` is the - * muted gray inline link. Optional and additive — it only affects `variant="link"`, so it has no - * default and every other `variant` ignores it. - */ - emphasis?: ButtonEmphasis; - /** - * Layout axis (Figma "Full width"). `auto` keeps the button inline-sized (default); `full` - * stretches it to fill its container (`w-full`). Pass `stretch="full"` for forms or full-width - * call-to-action placements. - */ - stretch?: ButtonStretch; -}; - -export type ButtonProps = Omit & ButtonOwnProps; +// The variant axes come straight from `ButtonVariantProps` (required where there is no default, +// optional where there is — `emphasis`/`stretch`). +export type ButtonProps = Omit & ButtonVariantProps; /** * A plain accessible button built on propel's design tokens. Pick a look with `variant` (Figma diff --git a/packages/propel/src/ui/button/variants.ts b/packages/propel/src/ui/button/variants.ts index a58370e4..520ae6e4 100644 --- a/packages/propel/src/ui/button/variants.ts +++ b/packages/propel/src/ui/button/variants.ts @@ -1,6 +1,12 @@ import { cva, cx, type VariantProps } from "class-variance-authority"; import { nodeSlotClass } from "../../internal/node-slot"; +import { type StrictVariantProps } from "../../internal/variant-props"; + +// The ONLY optional Button axes, each with a real fallback: `stretch` defaults to inline `auto`; +// `emphasis` (link-only) defaults to the `solid` blue link. Every other axis has no default and is +// therefore required (see `StrictVariantProps`). +const buttonDefaultVariants = { emphasis: "solid", stretch: "auto" } as const; // Magnitudes follow the Figma "Buttons" Size scale. Figma ships S/Base/L/XL; those // map to sm/md/lg/xl by their px heights (20/24/28/32). Per Figma: @@ -158,15 +164,23 @@ export const buttonVariants = cva( ), }, ], + defaultVariants: buttonDefaultVariants, }, ); -export type ButtonVariantProps = VariantProps; -export type ButtonVariant = NonNullable; -export type ButtonTone = NonNullable; -export type ButtonMagnitude = NonNullable; -export type ButtonEmphasis = NonNullable; -export type ButtonStretch = NonNullable; +type ButtonVariantConfig = VariantProps; +export type ButtonVariant = NonNullable; +export type ButtonTone = NonNullable; +export type ButtonMagnitude = NonNullable; +export type ButtonEmphasis = NonNullable; +export type ButtonStretch = NonNullable; + +// `variant`/`tone`/`magnitude` are required (no default); `emphasis`/`stretch` are optional +// (they have defaults). The whole point of `StrictVariantProps`. +export type ButtonVariantProps = StrictVariantProps< + typeof buttonVariants, + keyof typeof buttonDefaultVariants +>; // The text label inside a Button. When the parent button is `aria-busy` (loading) // it dims via the `group-aria-busy:` sibling of the `group` class on the root: the From 5b792565740e887c3729714848bfc94009b8035f Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Wed, 24 Jun 2026 21:30:42 +0700 Subject: [PATCH 17/86] Add propel component operating protocol (packages/propel/CLAUDE.md) 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. --- packages/propel/CLAUDE.md | 120 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 packages/propel/CLAUDE.md diff --git a/packages/propel/CLAUDE.md b/packages/propel/CLAUDE.md new file mode 100644 index 00000000..7333e8e0 --- /dev/null +++ b/packages/propel/CLAUDE.md @@ -0,0 +1,120 @@ +# Propel component operating protocol + +`@plane/propel` is a React component library built on Base UI + Tailwind v4. This file is the +**exact** protocol for building and changing components. Follow it literally; when something here +conflicts with an older habit, this wins. + +## Tiers — what goes where + +Code flows in one direction only: **`base` → `ui` → `components` → `patterns`**. A tier may import +from tiers below it, never above. `internal/` is shared implementation usable by any tier. + +| Tier | Path | What it is | May contain | +| --- | --- | --- | --- | +| **base** | `src/base//` | Extensions of Base UI — primitives we add where Base UI has a gap (e.g. `BaseTextArea` = `Field.Control` rendering `