From 081c02407426f57f1ad6aca477e6c3743e387527 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Wed, 17 Jun 2026 14:12:24 +0700 Subject: [PATCH 1/5] Fix switch thumb shadow and dropdown priority colors Both rendered blank: the switch thumb used shadow-sm and the dropdown story icons used text-orange/amber/blue-500, all of which propel resets out of the default Tailwind scales. Use shadow-raised-100 and the propel label-icon tokens. --- .../src/components/dropdown/dropdown.stories.tsx | 10 +++++++--- packages/propel/src/components/switch/index.tsx | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/propel/src/components/dropdown/dropdown.stories.tsx b/packages/propel/src/components/dropdown/dropdown.stories.tsx index 2fe2cab5..4cfcb23b 100644 --- a/packages/propel/src/components/dropdown/dropdown.stories.tsx +++ b/packages/propel/src/components/dropdown/dropdown.stories.tsx @@ -142,9 +142,13 @@ const STATUSES = [ // Priority glyphs (Figma "Priority" demo). const PRIORITIES = [ { key: "urgent", label: "Urgent", icon: }, - { key: "high", label: "High", icon: }, - { key: "medium", label: "Medium", icon: }, - { key: "low", label: "Low", icon: }, + { key: "high", label: "High", icon: }, + { + key: "medium", + label: "Medium", + icon: , + }, + { key: "low", label: "Low", icon: }, { key: "none", label: "None", icon: }, ] as const; diff --git a/packages/propel/src/components/switch/index.tsx b/packages/propel/src/components/switch/index.tsx index ef9c44ab..5bdd3e0f 100644 --- a/packages/propel/src/components/switch/index.tsx +++ b/packages/propel/src/components/switch/index.tsx @@ -32,7 +32,7 @@ const trackVariants = cva( // The thumb stays white in every theme and in both on/off states. `surface-1` // is white in light mode but flips dark in dark mode, so the thumb pins to the // on-color token (the white-on-tone color) regardless of checked state. -const thumbVariants = cva("shadow-sm rounded-full bg-on-color transition-transform", { +const thumbVariants = cva("rounded-full bg-on-color shadow-raised-100 transition-transform", { variants: { magnitude: { lg: "size-4 data-[checked]:translate-x-[12px]", From 780a8c089b52c9e428d3092e4a936fc78536a043 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Wed, 17 Jun 2026 14:12:24 +0700 Subject: [PATCH 2/5] RadioGroup: add a density prop, drop the cryptic gap override Adds density (comfortable | compact) so consumers set row spacing on the group instead of reaching into it with [&>[role=radiogroup]]:gap-0 from the outside. The popover panels now pass density="compact". --- .../components/popover/popover.stories.tsx | 38 ++++++++++--------- .../propel/src/components/radio/index.tsx | 27 +++++++++++-- .../src/components/radio/radio.stories.tsx | 33 ++++++++++++++++ 3 files changed, 76 insertions(+), 22 deletions(-) diff --git a/packages/propel/src/components/popover/popover.stories.tsx b/packages/propel/src/components/popover/popover.stories.tsx index 389074d3..6809c717 100644 --- a/packages/propel/src/components/popover/popover.stories.tsx +++ b/packages/propel/src/components/popover/popover.stories.tsx @@ -155,15 +155,16 @@ export const DisplayProperties: Story = { Group by - {/* Collapse RadioGroup's default row gap so the rows sit flush like the - dropdown's menu items. */} -
- setGroupBy(String(v))}> - {["Priority", "State", "Cycle", "Labels"].map((g) => ( - - ))} - -
+ {/* Rows sit flush like the dropdown's menu items. */} + setGroupBy(String(v))} + > + {["Priority", "State", "Cycle", "Labels"].map((g) => ( + + ))} +
{isOpen && key === "order" ? ( - // Items within an expanded category sit flush (0 spacing): collapse - // RadioGroup's default row gap from the parent. -
- setOrder(String(v))}> - {ORDER.map((o) => ( - - ))} - -
+ // Items within an expanded category sit flush (0 spacing). + setOrder(String(v))} + > + {ORDER.map((o) => ( + + ))} + ) : null}
); diff --git a/packages/propel/src/components/radio/index.tsx b/packages/propel/src/components/radio/index.tsx index 19bbc3ac..84ee3da7 100644 --- a/packages/propel/src/components/radio/index.tsx +++ b/packages/propel/src/components/radio/index.tsx @@ -1,6 +1,6 @@ import { Radio as BaseRadio } from "@base-ui/react/radio"; import { RadioGroup as BaseRadioGroup } from "@base-ui/react/radio-group"; -import { cva, cx } from "class-variance-authority"; +import { cva, cx, type VariantProps } from "class-variance-authority"; import * as React from "react"; // The Figma "Radiobutton" component (node 2159-4535) defines a single 16px control @@ -31,18 +31,37 @@ const radioVariants = cva( ), ); +// Row spacing is a property of the group, not something a consumer should reach in and +// override from outside. `comfortable` is the default 8px rhythm; `compact` sits the rows +// flush (e.g. a settings panel where options read like menu items). +const radioGroupVariants = cva("flex flex-col", { + variants: { + density: { + comfortable: "gap-2", + compact: "gap-0", + }, + }, +}); + +export type RadioGroupDensity = NonNullable["density"]>; + export type RadioGroupProps = Omit< React.ComponentProps, "className" | "render" | "style" ->; +> & { + /** Spacing between options: `comfortable` (default, 8px) or `compact` (flush). */ + density?: RadioGroupDensity; +}; /** * Groups a set of `Radio` options so at most one can be selected at a time (none is selected until * a `value`/`defaultValue` is set). Wrap `Radio` children in it and drive the selection with * `value`/`defaultValue` + `onValueChange`. Renders a `
` with `role="radiogroup"`. + * + * @param density - Row spacing; `comfortable` (default) or `compact` (flush). */ -export function RadioGroup(props: RadioGroupProps) { - return ; +export function RadioGroup({ density = "comfortable", ...props }: RadioGroupProps) { + return ; } export type RadioProps = Omit< diff --git a/packages/propel/src/components/radio/radio.stories.tsx b/packages/propel/src/components/radio/radio.stories.tsx index 4918e749..cbe6cdaf 100644 --- a/packages/propel/src/components/radio/radio.stories.tsx +++ b/packages/propel/src/components/radio/radio.stories.tsx @@ -41,6 +41,39 @@ export const Default: Story = { ), }; +/** + * Row spacing is the group's own `density` prop — `comfortable` (default, 8px gap) or `compact` + * (flush rows, e.g. a settings panel where options read like menu items). Consumers set the axis on + * the component rather than overriding the gap from the outside. + */ +export const Density: Story = { + parameters: { controls: { disable: true } }, + render: () => ( +
+ + + + + + + + +
+ ), +}; + /** * The states from Figma side by side: unselected, selected, disabled, and read-only — each driven * by the primitive, not by a variant. From 45355d3112963f15ce716ef753097043a4d3b2f4 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Wed, 17 Jun 2026 14:12:24 +0700 Subject: [PATCH 3/5] Adopt useControllableState in Search, ExpandableSearch and NavItemHeader Replaces the hand-rolled isControlled / internal-useState / commit pattern with the shared hook, matching Dropdown. --- .../propel/src/components/nav-item/index.tsx | 13 +++++---- .../propel/src/components/search/index.tsx | 28 ++++++++----------- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/packages/propel/src/components/nav-item/index.tsx b/packages/propel/src/components/nav-item/index.tsx index 2686cd4f..a18b0ff6 100644 --- a/packages/propel/src/components/nav-item/index.tsx +++ b/packages/propel/src/components/nav-item/index.tsx @@ -2,6 +2,7 @@ import { useRender } from "@base-ui/react/use-render"; import { cva, cx, type VariantProps } from "class-variance-authority"; import * as React from "react"; +import { useControllableState } from "../../hooks/use-controllable-state/index"; import { nodeSlotClass } from "../../internal/node-slot"; // The Figma "Nav item" component (node 1329-396) is a single clickable sidebar row: @@ -261,9 +262,11 @@ export function NavItemHeader({ onClick, ...props }: NavItemHeaderProps) { - const isControlled = expanded !== undefined; - const [uncontrolledExpanded, setUncontrolledExpanded] = React.useState(defaultExpanded); - const isExpanded = isControlled ? expanded : uncontrolledExpanded; + const [isExpanded, setExpanded] = useControllableState({ + value: expanded, + defaultValue: defaultExpanded, + onChange: onExpandedChange, + }); return ( // `--node-size` (16px) sizes any raw glyph dropped into the inline-end action slot, the @@ -280,9 +283,7 @@ export function NavItemHeader({ if (event.currentTarget.getAttribute("aria-disabled") === "true") return; onClick?.(event); if (event.defaultPrevented) return; - const next = !isExpanded; - if (!isControlled) setUncontrolledExpanded(next); - onExpandedChange?.(next); + setExpanded(!isExpanded); }} > {children} diff --git a/packages/propel/src/components/search/index.tsx b/packages/propel/src/components/search/index.tsx index 2312dba2..ac232051 100644 --- a/packages/propel/src/components/search/index.tsx +++ b/packages/propel/src/components/search/index.tsx @@ -3,6 +3,8 @@ import { cx } from "class-variance-authority"; import { Search as SearchIcon, X } from "lucide-react"; import * as React from "react"; +import { useControllableState } from "../../hooks/use-controllable-state/index"; + // The Figma "Search" component (node 1393-45336) is a single-line search field: a // 32px-tall, 8px-radius box with a leading magnifier, the value, and a trailing clear // (✕) button that appears once there's text. Built on Base UI `Input` (the text-input @@ -54,16 +56,13 @@ export function Search({ "aria-labelledby": ariaLabelledBy, ...props }: SearchProps) { - const isControlled = value !== undefined; - const [internalValue, setInternalValue] = React.useState(defaultValue ?? ""); - const currentValue = isControlled ? value : internalValue; + const [currentValue, commit] = useControllableState({ + value, + defaultValue: defaultValue ?? "", + onChange: onValueChange, + }); const inputRef = React.useRef(null); - const commit = (next: string) => { - if (!isControlled) setInternalValue(next); - onValueChange?.(next); - }; - const hasValue = currentValue != null && currentValue !== ""; // Only default to "Search" when the consumer gives the field no name of its own. An // `aria-label` would override an `aria-labelledby`, so skip it when one is provided. @@ -177,9 +176,11 @@ export function ExpandableSearch({ "aria-labelledby": ariaLabelledBy, ...props }: ExpandableSearchProps) { - const isControlled = value !== undefined; - const [internalValue, setInternalValue] = React.useState(defaultValue ?? ""); - const currentValue = isControlled ? value : internalValue; + const [currentValue, commit] = useControllableState({ + value, + defaultValue: defaultValue ?? "", + onChange: onValueChange, + }); const hasValue = currentValue != null && currentValue !== ""; // Only default to "Search" when the consumer gives the field no name of its own. An // `aria-label` would override an `aria-labelledby`, so skip it when one is provided. @@ -190,11 +191,6 @@ export function ExpandableSearch({ const showExpanded = focused || hasValue; const inputRef = React.useRef(null); - const commit = (next: string) => { - if (!isControlled) setInternalValue(next); - onValueChange?.(next); - }; - return (
{/* The box animates its width between the icon and the field. A `