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
1 change: 1 addition & 0 deletions packages/propel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "7.0.0-dev.20260509.2",
"@vitest/browser-playwright": "4.1.9",
"@vitest/coverage-v8": "4.1.8",
"playwright": "^1.61.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { Accordion, AccordionItem, AccordionPanel, AccordionTrigger } from "./in
// column plus their prop-driven states (checked / disabled / error).
// * Static components with no interaction-state styling (badge / banner / avatar /
// tooltip-popup) get NO pseudo-states story.
// * Base UI `data-[checked]` / `data-[disabled]` states are NOT forced by the
// * Base UI `data-checked` / `data-disabled` states are NOT forced by the
// pseudo addon — those are attribute selectors, not pseudo-classes — so they must
// be shown via real props, not the `pseudo` parameter.
const meta = {
Expand Down
6 changes: 3 additions & 3 deletions packages/propel/src/components/accordion/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export function AccordionTrigger({ leadingIcon, children, ...props }: AccordionT
<span className="flex-1">{children}</span>
<ChevronDown
aria-hidden
className="size-3.5 shrink-0 text-icon-secondary transition-transform duration-200 group-data-[panel-open]:rotate-180"
className="size-3.5 shrink-0 text-icon-secondary transition-transform duration-200 group-data-panel-open:rotate-180"
/>
</BaseAccordion.Trigger>
</BaseAccordion.Header>
Expand All @@ -92,10 +92,10 @@ export function AccordionPanel({ children, ...props }: AccordionPanelProps) {
return (
<BaseAccordion.Panel
className={cx(
"h-[var(--accordion-panel-height)] overflow-hidden",
"h-(--accordion-panel-height) overflow-hidden",
"text-14 text-secondary",
"transition-[height] duration-200 ease-out",
"data-[ending-style]:h-0 data-[starting-style]:h-0",
"data-ending-style:h-0 data-starting-style:h-0",
)}
{...props}
>
Expand Down
19 changes: 12 additions & 7 deletions packages/propel/src/components/breadcrumb/breadcrumb.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,7 @@ export const DropdownInteraction: Story = {
await userEvent.click(trigger);
// Once open, the collapsed crumbs are real menu items (Base UI portals the
// menu to <body>, so query the page document, not just the story canvas).
const menu = await within(document.body).findByRole("menu");
const items = within(menu).getAllByRole("menuitem");
const items = await within(document.body).findAllByRole("menuitem");
await expect(items).toHaveLength(2);
await expect(items[0]).toHaveTextContent("Projects");
await expect(items[1]).toHaveTextContent("Design");
Expand Down Expand Up @@ -265,11 +264,13 @@ export const KeyboardNavigation: Story = {
trigger.focus();
await expect(trigger).toHaveFocus();
await userEvent.keyboard("{ArrowDown}");
await waitFor(() => expect(body.getByRole("menu")).toBeInTheDocument());
await expect(await body.findByRole("menuitem", { name: "Plane Web" })).toBeInTheDocument();
});

await step("arrow-nav + Enter selects a sibling", async () => {
const menu = body.getByRole("menu");
const firstItem = await body.findByRole("menuitem", { name: "Plane Web" });
const menu = firstItem.closest('[role="menu"]');
if (!(menu instanceof HTMLElement)) throw new Error("breadcrumb menu not found");
const items = within(menu).getAllByRole("menuitem");
await expect(items).toHaveLength(2);
// Opening with ArrowDown highlights the first item; one more ArrowDown moves to
Expand All @@ -278,15 +279,19 @@ export const KeyboardNavigation: Story = {
await waitFor(() => expect(items[1]).toHaveAttribute("data-highlighted"));
await userEvent.keyboard("{Enter}");
// Selecting closes the menu.
await waitFor(() => expect(body.queryByRole("menu")).toBeNull());
await waitFor(() =>
expect(body.queryByRole("menuitem", { name: "Plane Web" })).not.toBeInTheDocument(),
);
});

await step("Escape closes the menu and returns focus to the trigger", async () => {
trigger.focus();
await userEvent.keyboard("{Enter}");
await waitFor(() => expect(body.getByRole("menu")).toBeInTheDocument());
await expect(await body.findByRole("menuitem", { name: "Plane Web" })).toBeInTheDocument();
await userEvent.keyboard("{Escape}");
await waitFor(() => expect(body.queryByRole("menu")).toBeNull());
await waitFor(() =>
expect(body.queryByRole("menuitem", { name: "Plane Web" })).not.toBeInTheDocument(),
);
await expect(trigger).toHaveFocus();
});
},
Expand Down
8 changes: 4 additions & 4 deletions packages/propel/src/components/breadcrumb/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ export function BreadcrumbDropdown({
aria-label={label}
className={cx(
crumbVariants({ interactive: true }),
"cursor-default data-[popup-open]:bg-layer-transparent-hover data-[popup-open]:text-primary",
"cursor-default data-popup-open:bg-layer-transparent-hover data-popup-open:text-primary",
)}
{...props}
>
Expand Down Expand Up @@ -155,7 +155,7 @@ export type BreadcrumbDropdownItemProps = Omit<
export function BreadcrumbDropdownItem(props: BreadcrumbDropdownItemProps) {
return (
<Menu.Item
className="flex cursor-default items-center rounded-sm px-2 py-1 text-14 leading-[1.54] text-secondary outline-none select-none data-[highlighted]:bg-layer-transparent-hover data-[highlighted]:text-primary"
className="flex cursor-default items-center rounded-sm px-2 py-1 text-14 leading-[1.54] text-secondary outline-none select-none data-highlighted:bg-layer-transparent-hover data-highlighted:text-primary"
{...props}
/>
);
Expand Down Expand Up @@ -208,7 +208,7 @@ export function BreadcrumbMenuTrigger({ icon, children, ...props }: BreadcrumbMe
<Menu.Trigger
className={cx(
crumbVariants({ interactive: true }),
"group/trigger cursor-default data-[popup-open]:bg-layer-transparent-hover data-[popup-open]:text-primary",
"group/trigger cursor-default data-popup-open:bg-layer-transparent-hover data-popup-open:text-primary",
)}
{...props}
>
Expand All @@ -223,7 +223,7 @@ export function BreadcrumbMenuTrigger({ icon, children, ...props }: BreadcrumbMe
*closed* chevron so it points left along the mirrored trail — the open
(rotated-down) chevron must not be mirrored, or it would point up. */}
<ChevronRight
className="size-3.5 shrink-0 text-icon-tertiary transition-transform group-data-[popup-open]/trigger:rotate-90 rtl:not-group-data-[popup-open]/trigger:-scale-x-100"
className="size-3.5 shrink-0 text-icon-tertiary transition-transform group-data-popup-open/trigger:rotate-90 rtl:not-group-data-popup-open/trigger:-scale-x-100"
aria-hidden="true"
/>
</Menu.Trigger>
Expand Down
2 changes: 1 addition & 1 deletion packages/propel/src/components/button/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ export function Button({
{...props}
>
{loading ? (
<LoaderCircle aria-hidden className="size-[var(--node-size)] animate-spin" />
<LoaderCircle aria-hidden className="size-(--node-size) animate-spin" />
) : inlineStartNode ? (
<span aria-hidden className={nodeSlotClass}>
{inlineStartNode}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export const Error: Story = {
await expect(unchecked).toHaveClass("border-danger-strong");
// Checked danger box: accent-blue fill, like every other tone.
await expect(checked).toHaveAttribute("aria-checked", "true");
await expect(checked).toHaveClass("data-[checked]:bg-accent-primary");
await expect(checked).toHaveClass("data-checked:bg-accent-primary");
},
};

Expand Down
10 changes: 5 additions & 5 deletions packages/propel/src/components/checkbox/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ const checkboxVariants = cva(
// tone, so it lives here in the base rather than the tone variants. This
// keeps a tone-less `CheckboxVisual` from rendering a white check on no fill.
// A white icon sits on top. Base UI exposes these via `data-*` attributes.
"data-[checked]:border-transparent data-[checked]:bg-accent-primary data-[checked]:text-icon-on-color",
"data-[indeterminate]:border-transparent data-[indeterminate]:bg-accent-primary data-[indeterminate]:text-icon-on-color",
"data-checked:border-transparent data-checked:bg-accent-primary data-checked:text-icon-on-color",
"data-indeterminate:border-transparent data-indeterminate:bg-accent-primary data-indeterminate:text-icon-on-color",
// Disabled: muted border/fill and no pointer; overrides the checked fill.
"data-[disabled]:cursor-not-allowed data-[disabled]:border-disabled data-[disabled]:bg-transparent",
"data-[disabled]:data-[checked]:border-transparent data-[disabled]:data-[checked]:bg-layer-disabled data-[disabled]:data-[checked]:text-icon-disabled",
"data-[disabled]:data-[indeterminate]:border-transparent data-[disabled]:data-[indeterminate]:bg-layer-disabled data-[disabled]:data-[indeterminate]:text-icon-disabled",
"data-disabled:cursor-not-allowed data-disabled:border-disabled data-disabled:bg-transparent",
"data-disabled:data-checked:border-transparent data-disabled:data-checked:bg-layer-disabled data-disabled:data-checked:text-icon-disabled",
"data-disabled:data-indeterminate:border-transparent data-disabled:data-indeterminate:bg-layer-disabled data-disabled:data-indeterminate:text-icon-disabled",
),
{
variants: {
Expand Down
2 changes: 1 addition & 1 deletion packages/propel/src/components/icon-button/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export function IconButton({
{...props}
>
{loading ? (
<LoaderCircle aria-hidden className="size-[var(--node-size)] animate-spin" />
<LoaderCircle aria-hidden className="size-(--node-size) animate-spin" />
) : (
<span aria-hidden className={nodeSlotClass}>
{children}
Expand Down
8 changes: 4 additions & 4 deletions packages/propel/src/components/nav-item/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const navItemVariants = cva(
"hover:bg-layer-transparent-hover active:bg-layer-transparent-active active:text-primary",
"focus-visible:ring-2 focus-visible:ring-accent-strong",
// Selected (Figma "Selected" state): filled surface + primary text.
"data-[active]:bg-layer-transparent-selected data-[active]:text-primary",
"data-active:bg-layer-transparent-selected data-active:text-primary",
// Disabled: dimmed and non-interactive.
"disabled:pointer-events-none disabled:text-disabled aria-disabled:pointer-events-none aria-disabled:text-disabled",
),
Expand Down Expand Up @@ -120,7 +120,7 @@ export function NavItem({
className={cx(
"flex size-4 shrink-0 items-center justify-center text-icon-placeholder [&>svg]:size-full",
// Selected/pressed pull the leading icon up to the primary tone.
"group-active/nav-item:text-icon-primary group-data-[active]/nav-item:text-icon-primary",
"group-active/nav-item:text-icon-primary group-data-active/nav-item:text-icon-primary",
// Disabled dims the icon to match the dimmed label.
"group-disabled/nav-item:text-icon-disabled group-aria-disabled/nav-item:text-icon-disabled",
)}
Expand Down Expand Up @@ -294,7 +294,7 @@ export function NavItemHeader({
"flex size-4 shrink-0 items-center justify-center text-icon-secondary [&>svg]:size-full",
// The Figma glyph is a filled caret-down. Collapsed rotates it a quarter turn
// so it points at the inline-start; RTL mirrors so it still points inward.
"rotate-90 transition-transform data-[expanded]:rotate-0 rtl:-rotate-90 rtl:data-[expanded]:rotate-0",
"rotate-90 transition-transform data-expanded:rotate-0 rtl:-rotate-90 rtl:data-expanded:rotate-0",
)}
>
{chevron}
Expand Down Expand Up @@ -327,7 +327,7 @@ export function NavItemChevron({
data-open={open ? "" : undefined}
className={cx(
"flex size-4 shrink-0 items-center justify-center text-icon-placeholder [&>svg]:size-full",
"transition-transform data-[open]:rotate-180 rtl:-scale-x-100",
"transition-transform data-open:rotate-180 rtl:-scale-x-100",
)}
>
{icon}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export const PageSizeSelector: Story = {

await step("clicking the trigger opens the page-size menu", async () => {
await userEvent.click(canvas.getByRole("button", { name: /50 per page/i }));
await waitFor(() => expect(body.getByRole("menu")).toBeInTheDocument());
await expect(await body.findByRole("menuitem", { name: "25" })).toBeInTheDocument();
// All sizes are listed as menu items.
for (const n of [25, 50, 100]) {
await expect(body.getByRole("menuitem", { name: String(n) })).toBeInTheDocument();
Expand All @@ -108,10 +108,8 @@ export const PageSizeSelector: Story = {
await expect(trigger).toHaveFocus();
// ArrowDown opens the menu and highlights the first item (25).
await userEvent.keyboard("{ArrowDown}");
await waitFor(() => expect(body.getByRole("menu")).toBeInTheDocument());
await waitFor(() =>
expect(body.getByRole("menuitem", { name: "25" })).toHaveAttribute("data-highlighted"),
);
const firstItem = await body.findByRole("menuitem", { name: "25" });
await waitFor(() => expect(firstItem).toHaveAttribute("data-highlighted"));
// ArrowDown moves the highlight to the second item (50); Enter selects it.
await userEvent.keyboard("{ArrowDown}");
await waitFor(() =>
Expand Down
4 changes: 2 additions & 2 deletions packages/propel/src/components/pill/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ function PillNode({ children }: { children: React.ReactNode }) {
}

function PillSpinner() {
return <LoaderCircle aria-hidden className="size-[var(--node-size)] shrink-0 animate-spin" />;
return <LoaderCircle aria-hidden className="size-(--node-size) shrink-0 animate-spin" />;
}

// The truncating label. `min-w-0` lets it shrink so the 120px cap can ellipsize it.
Expand Down Expand Up @@ -128,7 +128,7 @@ const pillSwitchColors = cx(
"hover:border-strong hover:bg-layer-2-hover",
// Selected (the toggle's pressed state) is the darker `-selected` fill + strong
// border + primary label.
"data-[pressed]:border-strong data-[pressed]:bg-layer-2-selected data-[pressed]:text-primary",
"data-pressed:border-strong data-pressed:bg-layer-2-selected data-pressed:text-primary",
"disabled:cursor-not-allowed disabled:border-subtle-1 disabled:bg-layer-transparent disabled:text-disabled",
);

Expand Down
4 changes: 2 additions & 2 deletions packages/propel/src/components/radio/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ const radioVariants = cva(
"flex size-4 shrink-0 items-center justify-center rounded-full border-sm border-current bg-layer-1",
"text-icon-tertiary transition-colors outline-none",
// Selected uses the accent color for both the ring and the dot.
"data-[checked]:text-icon-accent-primary",
"data-checked:text-icon-accent-primary",
// Keyboard focus ring, drawn outside the control so it never clips the dot.
"focus-visible:ring-2 focus-visible:ring-accent-strong focus-visible:ring-offset-2",
// Disabled is dimmed and non-interactive.
"data-[disabled]:cursor-not-allowed data-[disabled]:text-icon-disabled data-[disabled]:opacity-60",
"data-disabled:cursor-not-allowed data-disabled:text-icon-disabled data-disabled:opacity-60",
),
);

Expand Down
6 changes: 3 additions & 3 deletions packages/propel/src/components/search/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,11 +148,11 @@ const expandableBoxClass = cx(
"transition-[width,border-color,background-color] duration-200 ease-out motion-reduce:transition-none",
// Collapsed it reads as an icon button (hover fill). It never rests focused — focusing
// the field expands it — so the focus ring lives on the expanded chrome below.
"not-data-[expanded]:hover:bg-layer-transparent-hover",
"not-data-expanded:hover:bg-layer-transparent-hover",
// Expanded: widen to the full field and show the search-box chrome (subtle border +
// layer-2 fill at rest, accent border + 1px accent ring on focus).
"data-[expanded]:w-[204px] data-[expanded]:border-subtle-1 data-[expanded]:bg-layer-2",
"data-[expanded]:focus-within:border-accent-strong data-[expanded]:focus-within:ring-1 data-[expanded]:focus-within:ring-accent-strong/35",
"data-expanded:w-[204px] data-expanded:border-subtle-1 data-expanded:bg-layer-2",
"data-expanded:focus-within:border-accent-strong data-expanded:focus-within:ring-1 data-expanded:focus-within:ring-accent-strong/35",
);

export type ExpandableSearchProps = SearchProps;
Expand Down
10 changes: 5 additions & 5 deletions packages/propel/src/components/switch/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ const trackVariants = cva(
cx(
"relative inline-flex shrink-0 items-center rounded-full p-px transition-colors",
// Off track = Figma icon/placeholder; on track = accent/primary.
"bg-icon-placeholder data-[checked]:bg-accent-primary",
"bg-icon-placeholder data-checked:bg-accent-primary",
// Unchangeable (disabled or readonly) dims the whole control to 40%,
// matching Figma's "Unchangeable" states. Disabled also blocks the cursor.
"data-[disabled]:cursor-not-allowed data-[disabled]:opacity-40 data-[readonly]:opacity-40",
"data-disabled:cursor-not-allowed data-disabled:opacity-40 data-readonly:opacity-40",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-strong",
),
{
Expand All @@ -35,9 +35,9 @@ const trackVariants = cva(
const thumbVariants = cva("rounded-full bg-on-color shadow-raised-100 transition-transform", {
variants: {
magnitude: {
lg: "size-4 data-[checked]:translate-x-[12px]",
md: "size-3.5 data-[checked]:translate-x-[11px]",
sm: "size-3 data-[checked]:translate-x-[9px]",
lg: "size-4 data-checked:translate-x-[12px]",
md: "size-3.5 data-checked:translate-x-[11px]",
sm: "size-3 data-checked:translate-x-[9px]",
},
},
});
Expand Down
2 changes: 1 addition & 1 deletion packages/propel/src/components/table/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ export function TableCell({
const actionableTriggerClass = cx(
"flex h-11 w-full items-center outline-none",
"bg-layer-transparent hover:bg-layer-transparent-hover focus-visible:bg-layer-transparent-hover",
"data-[popup-open]:bg-layer-transparent-active",
"data-popup-open:bg-layer-transparent-active",
"disabled:pointer-events-none disabled:text-disabled",
);

Expand Down
6 changes: 3 additions & 3 deletions packages/propel/src/components/tabs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ const tabVariants = cva(
variants: {
variant: {
contained:
"inline-flex h-6 items-center justify-center gap-1 rounded-md border-sm border-transparent px-1.5 text-13 text-secondary hover:text-primary data-[active]:border-subtle-1 data-[active]:bg-layer-2 data-[active]:text-primary data-[active]:shadow-raised-200",
"inline-flex h-6 items-center justify-center gap-1 rounded-md border-sm border-transparent px-1.5 text-13 text-secondary hover:text-primary data-active:border-subtle-1 data-active:bg-layer-2 data-active:text-primary data-active:shadow-raised-200",
underline: "group/tab inline-flex flex-col items-stretch gap-2 text-14",
},
},
Expand All @@ -143,7 +143,7 @@ const tabVariants = cva(
// layout and the bar track. `contained` renders its label inline, so this is
// only used for `underline`.
const underlineLabelVariants = cva(
"flex h-7 items-center justify-center gap-1.5 rounded-md px-2 py-0.5 text-tertiary transition-colors group-hover/tab:bg-layer-transparent-hover group-hover/tab:text-secondary group-data-[active]/tab:bg-layer-transparent-selected group-data-[active]/tab:text-primary",
"flex h-7 items-center justify-center gap-1.5 rounded-md px-2 py-0.5 text-tertiary transition-colors group-hover/tab:bg-layer-transparent-hover group-hover/tab:text-secondary group-data-active/tab:bg-layer-transparent-selected group-data-active/tab:text-primary",
);

// The 3px bar track under an `underline` tab. The track is inset `px-2` (8px)
Expand All @@ -154,7 +154,7 @@ const underlineLabelVariants = cva(
// (primary), so the active tab's own bar stays transparent to avoid doubling.
const underlineBarTrackVariants = cva("flex px-2");
const underlineBarVariants = cva(
"h-[3px] w-full rounded-full bg-current text-transparent transition-colors group-hover/tab:text-icon-placeholder group-data-[active]/tab:text-transparent",
"h-[3px] w-full rounded-full bg-current text-transparent transition-colors group-hover/tab:text-icon-placeholder group-data-active/tab:text-transparent",
);

// The leading-icon box. Sized to 16px (`Icon`) and tinted via `currentColor`,
Expand Down
2 changes: 1 addition & 1 deletion packages/propel/src/components/toast/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ export function Toast({ toast, ...props }: ToastProps) {
className={cx(
surfaceVariants({ elevation: "raised", radius: "lg" }),
"relative flex w-full items-start gap-2 px-4 py-3",
"transition-opacity data-[ending]:opacity-0",
"transition-opacity data-ending:opacity-0",
)}
{...props}
>
Expand Down
Loading