diff --git a/packages/propel/src/components/avatar/index.tsx b/packages/propel/src/components/avatar/index.tsx index 8fe11dab..f423036f 100644 --- a/packages/propel/src/components/avatar/index.tsx +++ b/packages/propel/src/components/avatar/index.tsx @@ -84,6 +84,10 @@ export type AvatarProps = Omit< fallback?: React.ReactNode; /** Initials background color. Defaults to a stable color derived from `alt`. */ tone?: AvatarTone; + /** + * Avatar size. Optional because an `Avatar` inside an `AvatarGroup` inherits the group's + * magnitude; standalone it falls back to `md`. + */ magnitude?: AvatarMagnitude; }; @@ -93,10 +97,10 @@ export function Avatar({ magnitude, src, alt, fallback, tone, ...props }: Avatar // it off `src` would miss the load/error case. Initials = a label tone color + // white text; the person icon = the neutral layer + a muted placeholder icon. const hasInitials = fallback != null; - // An explicit `magnitude` wins; otherwise inherit the group's (if inside one). - // There is no default — size must come from the prop or an enclosing AvatarGroup. + // An explicit `magnitude` wins; otherwise inherit the group's (if inside one); a + // standalone avatar with neither falls back to `md` so it always has a size. const groupMagnitude = React.useContext(AvatarGroupContext); - const effectiveMagnitude = magnitude ?? groupMagnitude; + const effectiveMagnitude = magnitude ?? groupMagnitude ?? "md"; // The tone is auto-derived from the name unless explicitly set, so each person // gets a stable color without the caller having to choose one. const resolvedTone = tone ?? getAvatarTone(alt ?? ""); @@ -124,12 +128,7 @@ export function Avatar({ magnitude, src, alt, fallback, tone, ...props }: Avatar : "bg-layer-1 text-icon-placeholder", )} > - {fallback ?? ( - - )} + {fallback ?? } ); diff --git a/packages/propel/src/components/breadcrumb/breadcrumb.stories.tsx b/packages/propel/src/components/breadcrumb/breadcrumb.stories.tsx index 0c4f5513..b72f627f 100644 --- a/packages/propel/src/components/breadcrumb/breadcrumb.stories.tsx +++ b/packages/propel/src/components/breadcrumb/breadcrumb.stories.tsx @@ -44,6 +44,11 @@ const meta = { export default meta; type Story = StoryObj; +// A non-navigating anchor for the `render` prop of crumb dropdown/menu items. Activating a +// bare `href="#"` performs a full document navigation, which tears down the test page in the +// browser runner — so swallow the default click. Real apps pass a router link here instead. +const inertAnchor = () => event.preventDefault()} />; + /** A three-level trail ending in the current page. */ export const Default: Story = { render: () => ( @@ -87,8 +92,8 @@ export const WithDropdown: Story = { - }>Projects - }>Design + Projects + Design @@ -118,8 +123,8 @@ export const DropdownInteraction: Story = { - }>Projects - }>Design + Projects + Design @@ -163,9 +168,9 @@ export const WithMenuCrumb: Story = { Plane Design - } label="Plane Web" /> - } label="Plane Mobile" /> - } label="Plane Server" /> + + + @@ -199,10 +204,10 @@ export const MenuCrumbSelected: Story = { List - } label="List" /> - } label="Board" /> - } label="Calendar" /> - } label="Spreadsheet" /> + + + + @@ -231,20 +236,8 @@ export const KeyboardNavigation: Story = { Plane Design - {/* In a real breadcrumb the sibling switcher navigates via a router - (preventDefault + client-side nav). Activating a bare `href="#"` - here would perform a full document navigation, which tears down the - test page in the browser runner — so swallow the default. */} - event.preventDefault()} />} - label="Plane Web" - /> - event.preventDefault()} />} - label="Plane Mobile" - /> + + 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/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/popover/popover.stories.tsx b/packages/propel/src/components/popover/popover.stories.tsx index 389074d3..9414a0ad 100644 --- a/packages/propel/src/components/popover/popover.stories.tsx +++ b/packages/propel/src/components/popover/popover.stories.tsx @@ -54,6 +54,29 @@ function PanelRadioRow({ value, label }: { value: string; label: string }) { ); } +// The checkbox-toggle footer (show sub-work items / show empty groups) that closes +// several of the display panels. Each panel gets an independent copy that owns its +// own state; pass `defaultToggles` to start a key checked. +function ToggleFooter({ defaultToggles = {} }: { defaultToggles?: Record }) { + const [toggles, setToggles] = React.useState>(defaultToggles); + return ( +
+ setToggles((t) => ({ ...t, sub: Boolean(next) }))} + label={Show sub-work items} + /> + setToggles((t) => ({ ...t, empty: Boolean(next) }))} + label={Show empty groups} + /> +
+ ); +} + /** * The default popover: a trigger plus a generic floating panel. The panel hosts arbitrary content * (here a couple of checkbox toggles) — it is NOT a `role="menu"`, so these controls are valid @@ -61,32 +84,16 @@ function PanelRadioRow({ value, label }: { value: string; label: string }) { * the trigger again. */ export const Default: Story = { - render: function DefaultStory() { - const [toggles, setToggles] = React.useState>({ sub: true }); - return ( - - }> - Options - - -
- setToggles((t) => ({ ...t, sub: Boolean(next) }))} - label={Show sub-work items} - /> - setToggles((t) => ({ ...t, empty: Boolean(next) }))} - label={Show empty groups} - /> -
-
-
- ); - }, + render: () => ( + + }> + Options + + + + + + ), play: async ({ canvas, step }) => { await step("open the popover and toggle a checkbox", async () => { const trigger = canvas.getByRole("button", { name: "Options" }); @@ -133,7 +140,6 @@ export const DisplayProperties: Story = { "Due date": true, }); const [groupBy, setGroupBy] = React.useState("state"); - const [toggles, setToggles] = React.useState>({}); return ( }> @@ -155,30 +161,18 @@ 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) => ( + + ))} + -
- setToggles((t) => ({ ...t, sub: Boolean(next) }))} - label={Show sub-work items} - /> - setToggles((t) => ({ ...t, empty: Boolean(next) }))} - label={Show empty groups} - /> -
+
); @@ -213,7 +207,6 @@ export const DisplayAccordion: Story = { render: function DisplayAccordionStory() { const [open, setOpen] = React.useState("order"); const [order, setOrder] = React.useState("priority"); - const [toggles, setToggles] = React.useState>({}); const SECTIONS = ["Display Properties", "Group by", "Sub Group by", "Order by"]; const ORDER = ["Manual - Rank", "Last created", "Last updated", "Priority", "Due date"]; return ( @@ -241,34 +234,22 @@ export const DisplayAccordion: Story = { )} {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} ); })} -
- setToggles((t) => ({ ...t, sub: Boolean(next) }))} - label={Show sub-work items} - /> - setToggles((t) => ({ ...t, empty: Boolean(next) }))} - label={Show empty groups} - /> -
+ ); 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. diff --git a/packages/propel/src/components/scroll-area/scroll-area.stories.tsx b/packages/propel/src/components/scroll-area/scroll-area.stories.tsx index 24214b39..93d32fb8 100644 --- a/packages/propel/src/components/scroll-area/scroll-area.stories.tsx +++ b/packages/propel/src/components/scroll-area/scroll-area.stories.tsx @@ -43,6 +43,25 @@ export const Default: Story = { }, }; +/** Horizontal overflow only: a single horizontal scrollbar, shown on demand. */ +export const Horizontal: Story = { + args: { + orientation: "horizontal", + children: ( +
+ {Array.from({ length: 20 }, (_, i) => ( +
+ Card {i + 1} +
+ ))} +
+ ), + }, +}; + /** Both axes overflow: a vertical and a horizontal scrollbar, each shown on demand. */ export const BothAxes: Story = { args: { 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 `