Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 8 additions & 9 deletions packages/propel/src/components/avatar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand All @@ -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 ?? "");
Expand Down Expand Up @@ -124,12 +128,7 @@ export function Avatar({ magnitude, src, alt, fallback, tone, ...props }: Avatar
: "bg-layer-1 text-icon-placeholder",
)}
>
{fallback ?? (
<User
aria-hidden
className={effectiveMagnitude ? iconSizeByMagnitude[effectiveMagnitude] : undefined}
/>
)}
{fallback ?? <User aria-hidden className={iconSizeByMagnitude[effectiveMagnitude]} />}
</BaseAvatar.Fallback>
</BaseAvatar.Root>
);
Expand Down
43 changes: 18 additions & 25 deletions packages/propel/src/components/breadcrumb/breadcrumb.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;

// 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 = () => <a href="#" onClick={(event) => event.preventDefault()} />;

/** A three-level trail ending in the current page. */
export const Default: Story = {
render: () => (
Expand Down Expand Up @@ -87,8 +92,8 @@ export const WithDropdown: Story = {
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbDropdown>
<BreadcrumbDropdownItem render={<a href="#" />}>Projects</BreadcrumbDropdownItem>
<BreadcrumbDropdownItem render={<a href="#" />}>Design</BreadcrumbDropdownItem>
<BreadcrumbDropdownItem render={inertAnchor()}>Projects</BreadcrumbDropdownItem>
<BreadcrumbDropdownItem render={inertAnchor()}>Design</BreadcrumbDropdownItem>
</BreadcrumbDropdown>
</BreadcrumbItem>
<BreadcrumbSeparator />
Expand Down Expand Up @@ -118,8 +123,8 @@ export const DropdownInteraction: Story = {
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbDropdown>
<BreadcrumbDropdownItem render={<a href="#" />}>Projects</BreadcrumbDropdownItem>
<BreadcrumbDropdownItem render={<a href="#" />}>Design</BreadcrumbDropdownItem>
<BreadcrumbDropdownItem render={inertAnchor()}>Projects</BreadcrumbDropdownItem>
<BreadcrumbDropdownItem render={inertAnchor()}>Design</BreadcrumbDropdownItem>
</BreadcrumbDropdown>
</BreadcrumbItem>
<BreadcrumbSeparator />
Expand Down Expand Up @@ -163,9 +168,9 @@ export const WithMenuCrumb: Story = {
Plane Design
</BreadcrumbMenuTrigger>
<BreadcrumbMenuContent>
<BreadcrumbMenuItem variant="default" render={<a href="#" />} label="Plane Web" />
<BreadcrumbMenuItem variant="default" render={<a href="#" />} label="Plane Mobile" />
<BreadcrumbMenuItem variant="default" render={<a href="#" />} label="Plane Server" />
<BreadcrumbMenuItem variant="default" render={inertAnchor()} label="Plane Web" />
<BreadcrumbMenuItem variant="default" render={inertAnchor()} label="Plane Mobile" />
<BreadcrumbMenuItem variant="default" render={inertAnchor()} label="Plane Server" />
</BreadcrumbMenuContent>
</BreadcrumbMenu>
</BreadcrumbItem>
Expand Down Expand Up @@ -199,10 +204,10 @@ export const MenuCrumbSelected: Story = {
<BreadcrumbMenu>
<BreadcrumbMenuTrigger>List</BreadcrumbMenuTrigger>
<BreadcrumbMenuContent>
<BreadcrumbMenuItem variant="default" selected render={<a href="#" />} label="List" />
<BreadcrumbMenuItem variant="default" render={<a href="#" />} label="Board" />
<BreadcrumbMenuItem variant="default" render={<a href="#" />} label="Calendar" />
<BreadcrumbMenuItem variant="default" render={<a href="#" />} label="Spreadsheet" />
<BreadcrumbMenuItem variant="default" selected render={inertAnchor()} label="List" />
<BreadcrumbMenuItem variant="default" render={inertAnchor()} label="Board" />
<BreadcrumbMenuItem variant="default" render={inertAnchor()} label="Calendar" />
<BreadcrumbMenuItem variant="default" render={inertAnchor()} label="Spreadsheet" />
</BreadcrumbMenuContent>
</BreadcrumbMenu>
</BreadcrumbItem>
Expand Down Expand Up @@ -231,20 +236,8 @@ export const KeyboardNavigation: Story = {
Plane Design
</BreadcrumbMenuTrigger>
<BreadcrumbMenuContent>
{/* 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. */}
<BreadcrumbMenuItem
variant="default"
render={<a href="#" onClick={(event) => event.preventDefault()} />}
label="Plane Web"
/>
<BreadcrumbMenuItem
variant="default"
render={<a href="#" onClick={(event) => event.preventDefault()} />}
label="Plane Mobile"
/>
<BreadcrumbMenuItem variant="default" render={inertAnchor()} label="Plane Web" />
<BreadcrumbMenuItem variant="default" render={inertAnchor()} label="Plane Mobile" />
</BreadcrumbMenuContent>
</BreadcrumbMenu>
</BreadcrumbItem>
Expand Down
10 changes: 7 additions & 3 deletions packages/propel/src/components/dropdown/dropdown.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,13 @@ const STATUSES = [
// Priority glyphs (Figma "Priority" demo).
const PRIORITIES = [
{ key: "urgent", label: "Urgent", icon: <span className="text-12">⛔</span> },
{ key: "high", label: "High", icon: <SignalHigh className="text-orange-500 size-4" /> },
{ key: "medium", label: "Medium", icon: <SignalMedium className="text-amber-500 size-4" /> },
{ key: "low", label: "Low", icon: <SignalLow className="text-blue-500 size-4" /> },
{ key: "high", label: "High", icon: <SignalHigh className="size-4 text-label-orange-icon" /> },
{
key: "medium",
label: "Medium",
icon: <SignalMedium className="size-4 text-label-yellow-icon" />,
},
{ key: "low", label: "Low", icon: <SignalLow className="size-4 text-label-indigo-icon" /> },
{ key: "none", label: "None", icon: <span className="text-tertiary">—</span> },
] as const;

Expand Down
13 changes: 7 additions & 6 deletions packages/propel/src/components/nav-item/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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<boolean>({
value: expanded,
defaultValue: defaultExpanded,
onChange: onExpandedChange,
});

return (
// `--node-size` (16px) sizes any raw glyph dropped into the inline-end action slot, the
Expand All @@ -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);
}}
>
<span className="min-w-0 truncate text-body-xs-semibold">{children}</span>
Expand Down
129 changes: 55 additions & 74 deletions packages/propel/src/components/popover/popover.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,39 +54,46 @@ 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<string, boolean> }) {
const [toggles, setToggles] = React.useState<Record<string, boolean>>(defaultToggles);
return (
<div className="flex flex-col">
<Checkbox
tone="neutral"
checked={Boolean(toggles.sub)}
onCheckedChange={(next) => setToggles((t) => ({ ...t, sub: Boolean(next) }))}
label={<span className="flex-1">Show sub-work items</span>}
/>
<Checkbox
tone="neutral"
checked={Boolean(toggles.empty)}
onCheckedChange={(next) => setToggles((t) => ({ ...t, empty: Boolean(next) }))}
label={<span className="flex-1">Show empty groups</span>}
/>
</div>
);
}

/**
* 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
* children. Open it by clicking the trigger; dismiss with `Esc`, an outside click, or by toggling
* the trigger again.
*/
export const Default: Story = {
render: function DefaultStory() {
const [toggles, setToggles] = React.useState<Record<string, boolean>>({ sub: true });
return (
<Popover>
<PopoverTrigger render={<button type="button" className={triggerClass} />}>
Options
</PopoverTrigger>
<PopoverContent width="md" aria-label="Options">
<div className="flex flex-col">
<Checkbox
tone="neutral"
checked={Boolean(toggles.sub)}
onCheckedChange={(next) => setToggles((t) => ({ ...t, sub: Boolean(next) }))}
label={<span className="flex-1">Show sub-work items</span>}
/>
<Checkbox
tone="neutral"
checked={Boolean(toggles.empty)}
onCheckedChange={(next) => setToggles((t) => ({ ...t, empty: Boolean(next) }))}
label={<span className="flex-1">Show empty groups</span>}
/>
</div>
</PopoverContent>
</Popover>
);
},
render: () => (
<Popover>
<PopoverTrigger render={<button type="button" className={triggerClass} />}>
Options
</PopoverTrigger>
<PopoverContent width="md" aria-label="Options">
<ToggleFooter defaultToggles={{ sub: true }} />
</PopoverContent>
</Popover>
),
play: async ({ canvas, step }) => {
await step("open the popover and toggle a checkbox", async () => {
const trigger = canvas.getByRole("button", { name: "Options" });
Expand Down Expand Up @@ -133,7 +140,6 @@ export const DisplayProperties: Story = {
"Due date": true,
});
const [groupBy, setGroupBy] = React.useState("state");
const [toggles, setToggles] = React.useState<Record<string, boolean>>({});
return (
<Popover>
<PopoverTrigger render={<button type="button" className={triggerClass} />}>
Expand All @@ -155,30 +161,18 @@ export const DisplayProperties: Story = {
</div>
<PanelSeparator />
<PanelLabel>Group by</PanelLabel>
{/* Collapse RadioGroup's default row gap so the rows sit flush like the
dropdown's menu items. */}
<div className="[&>[role=radiogroup]]:gap-0">
<RadioGroup value={groupBy} onValueChange={(v) => setGroupBy(String(v))}>
{["Priority", "State", "Cycle", "Labels"].map((g) => (
<PanelRadioRow key={g} value={g.toLowerCase()} label={g} />
))}
</RadioGroup>
</div>
{/* Rows sit flush like the dropdown's menu items. */}
<RadioGroup
density="compact"
value={groupBy}
onValueChange={(v) => setGroupBy(String(v))}
>
{["Priority", "State", "Cycle", "Labels"].map((g) => (
<PanelRadioRow key={g} value={g.toLowerCase()} label={g} />
))}
</RadioGroup>
<PanelSeparator />
<div className="flex flex-col">
<Checkbox
tone="neutral"
checked={Boolean(toggles.sub)}
onCheckedChange={(next) => setToggles((t) => ({ ...t, sub: Boolean(next) }))}
label={<span className="flex-1">Show sub-work items</span>}
/>
<Checkbox
tone="neutral"
checked={Boolean(toggles.empty)}
onCheckedChange={(next) => setToggles((t) => ({ ...t, empty: Boolean(next) }))}
label={<span className="flex-1">Show empty groups</span>}
/>
</div>
<ToggleFooter />
</PopoverContent>
</Popover>
);
Expand Down Expand Up @@ -213,7 +207,6 @@ export const DisplayAccordion: Story = {
render: function DisplayAccordionStory() {
const [open, setOpen] = React.useState<string | null>("order");
const [order, setOrder] = React.useState("priority");
const [toggles, setToggles] = React.useState<Record<string, boolean>>({});
const SECTIONS = ["Display Properties", "Group by", "Sub Group by", "Order by"];
const ORDER = ["Manual - Rank", "Last created", "Last updated", "Priority", "Due date"];
return (
Expand Down Expand Up @@ -241,34 +234,22 @@ export const DisplayAccordion: Story = {
)}
</button>
{isOpen && key === "order" ? (
// Items within an expanded category sit flush (0 spacing): collapse
// RadioGroup's default row gap from the parent.
<div className="[&>[role=radiogroup]]:gap-0">
<RadioGroup value={order} onValueChange={(v) => setOrder(String(v))}>
{ORDER.map((o) => (
<PanelRadioRow key={o} value={o.toLowerCase()} label={o} />
))}
</RadioGroup>
</div>
// Items within an expanded category sit flush (0 spacing).
<RadioGroup
density="compact"
value={order}
onValueChange={(v) => setOrder(String(v))}
>
{ORDER.map((o) => (
<PanelRadioRow key={o} value={o.toLowerCase()} label={o} />
))}
</RadioGroup>
) : null}
</div>
);
})}
<PanelSeparator />
<div className="flex flex-col">
<Checkbox
tone="neutral"
checked={Boolean(toggles.sub)}
onCheckedChange={(next) => setToggles((t) => ({ ...t, sub: Boolean(next) }))}
label={<span className="flex-1">Show sub-work items</span>}
/>
<Checkbox
tone="neutral"
checked={Boolean(toggles.empty)}
onCheckedChange={(next) => setToggles((t) => ({ ...t, empty: Boolean(next) }))}
label={<span className="flex-1">Show empty groups</span>}
/>
</div>
<ToggleFooter />
</PopoverContent>
</Popover>
);
Expand Down
Loading