{Array.from({ length: 30 }, (_, i) => (
Line {i + 1} of wide content that overflows horizontally as well as down.
diff --git a/packages/propel/src/components/scroll-area/scroll-area.tsx b/packages/propel/src/components/scroll-area/scroll-area.tsx
new file mode 100644
index 0000000..0a09c47
--- /dev/null
+++ b/packages/propel/src/components/scroll-area/scroll-area.tsx
@@ -0,0 +1,65 @@
+import * as React from "react";
+
+import {
+ ScrollArea as ScrollAreaRoot,
+ ScrollAreaCorner,
+ ScrollAreaScrollbar,
+ ScrollAreaThumb,
+ ScrollAreaViewport,
+} from "../../ui/scroll-area";
+
+// propel's scroll container, built on Base UI ScrollArea. Unlike a re-skinned native
+// scrollbar it paints a real DOM thumb, so it renders consistently across browsers
+// (including Firefox and touch), insets the thumb from the edge, and reveals only on
+// interaction. The thumb uses propel's `--scrollbar-thumb*` tokens: shown while the
+// area is hovered or scrolling, darker on thumb hover, darkest while dragging, over a
+// transparent track. It fills its parent, so constrain the parent's height (or width)
+// to make the content scroll. The scrollbar + thumb styling is shared with components
+// that compose Base UI ScrollArea directly (e.g. Tabs) via `internal/scrollbar`.
+
+/** Which axes scroll. Drives which scrollbars (and the corner) are rendered. */
+export type ScrollAreaOrientation = "vertical" | "horizontal" | "both";
+
+export type ScrollAreaProps = {
+ /**
+ * Which axes scroll (required, no silent default like the other essential axes).
+ * `vertical`/`horizontal` render a single scrollbar; `both` renders both plus the corner. Render
+ * only the axes the content can actually overflow so an unused scrollbar never reserves space or
+ * reveals.
+ */
+ orientation: ScrollAreaOrientation;
+ /** The scrollable content. */
+ children: React.ReactNode;
+};
+
+/**
+ * A scroll container with propel's overlay scrollbar, built on Base UI `ScrollArea`. Use it to wrap
+ * any overflowing content (menus, panels, long lists). Place it as a child of a height-constrained
+ * flex column: it grows to fill the column and its viewport scrolls when the content overflows. The
+ * scrollbar shows only on hover/scroll and uses the propel scrollbar tokens.
+ */
+export function ScrollArea({ orientation, children }: ScrollAreaProps) {
+ const showVertical = orientation !== "horizontal";
+ const showHorizontal = orientation !== "vertical";
+ return (
+ // Sizing is a flex chain, not a percentage-height chain (which does not resolve
+ // through flex). The Root is a flex item that also lays its viewport out as a flex
+ // column; the Viewport is a `flex-1` child whose `overflow: scroll` (set by Base UI)
+ // gives it an automatic min-height of 0, so it bounds to the column and scrolls. The
+ // scrollbars are positioned absolutely by Base UI, so they never take flex space.
+
+ {children}
+ {showVertical ? (
+
+
+
+ ) : null}
+ {showHorizontal ? (
+
+
+
+ ) : null}
+ {orientation === "both" ? : null}
+
+ );
+}
diff --git a/packages/propel/src/components/search/index.tsx b/packages/propel/src/components/search/index.tsx
index 60ee0d4..4300d8a 100644
--- a/packages/propel/src/components/search/index.tsx
+++ b/packages/propel/src/components/search/index.tsx
@@ -1,250 +1,4 @@
-import { Input as BaseInput } from "@base-ui/react/input";
-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
-// primitive). States are element-driven — hover darkens the fill, `:focus-within` swaps
-// the border to `accent-strong` with a 2px accent ring and darkens the magnifier — not
-// props, so the only inputs are the value + placeholder.
-
-// The box chrome. `group/search` lets the magnifier react to `:focus-within`. The 2px
-// accent ring is the Figma "active" treatment; hover darkens the fill while resting.
-// The ring opacity is 25% per the Search frame (rgba(0,99,153,0.25)) — Input uses 20%,
-// so this difference is intentional, not drift.
-const searchBoxClass = cx(
- "group/search inline-flex h-8 w-full items-center gap-2 rounded-lg border-sm border-subtle-1 bg-layer-2 px-2",
- "transition-colors hover:bg-layer-2-hover",
- "focus-within:border-accent-strong focus-within:bg-layer-2 focus-within:ring-2 focus-within:ring-accent-strong/25",
- // Disabled: muted, no hover/ring (the Figma frame has no disabled state, but the
- // control is a real input and must degrade sensibly).
- "has-[:disabled]:cursor-not-allowed has-[:disabled]:bg-layer-2 has-[:disabled]:hover:bg-layer-2",
-);
-
-export type SearchProps = Omit<
- React.ComponentProps,
- "className" | "render" | "style" | "type" | "value" | "defaultValue" | "onValueChange"
-> & {
- /** Current text. Controlled; pair with `onValueChange`. */
- value?: string;
- /** Initial text. Uncontrolled. */
- defaultValue?: string;
- /** Called with the new text on each change (and on clear, with `""`). */
- onValueChange?: (value: string) => void;
- /** Placeholder text. @default "Search" */
- placeholder?: string;
- /** Accessible name for the field. @default "Search" */
- "aria-label"?: string;
-};
-
-/**
- * A search field — a leading magnifier, a text input, and a clear (✕) button that appears once
- * there's text. Built on Base UI `Input`. Drive it with `value` + `onValueChange` (controlled) or
- * `defaultValue` (uncontrolled). It fills its container's width; wrap it to constrain.
- */
-export function Search({
- value,
- defaultValue,
- onValueChange,
- placeholder = "Search",
- disabled,
- "aria-label": ariaLabel,
- "aria-labelledby": ariaLabelledBy,
- ...props
-}: SearchProps) {
- const [currentValue, commit] = useControllableState({
- value,
- defaultValue: defaultValue ?? "",
- onChange: onValueChange,
- });
- const inputRef = React.useRef(null);
-
- 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.
- const resolvedAriaLabel = ariaLabel ?? (ariaLabelledBy ? undefined : "Search");
-
- return (
- // A `` forwards clicks anywhere in the box (magnifier, padding) to the input;
- // the trailing clear button keeps its own onClick. It carries no text, so it adds
- // association without an accessible name — the input's aria-label still names it.
-
-
- commit(next)}
- placeholder={placeholder}
- disabled={disabled}
- aria-label={resolvedAriaLabel}
- aria-labelledby={ariaLabelledBy}
- className={cx(
- "min-w-0 flex-1 bg-transparent text-14 text-primary outline-none",
- "placeholder:text-placeholder disabled:cursor-not-allowed disabled:text-disabled",
- "[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none",
- )}
- {...props}
- />
- {hasValue && !disabled ? (
- {
- commit("");
- inputRef.current?.focus();
- }}
- className={cx(
- "inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-icon-secondary outline-none",
- "transition-colors hover:bg-layer-transparent-hover",
- "focus-visible:ring-2 focus-visible:ring-accent-strong",
- )}
- >
-
-
- ) : null}
-
- );
-}
-
-// ExpandableSearch (Figma node 2509:6515) is a search field that collapses to a 28px
-// magnifier to save space and expands to a 204px input on demand — the toolbar/header
-// pattern where search hides until needed. It expands while the field is focused (and
-// whenever it holds a value, so a filled field never collapses out from under its text)
-// and collapses again when blurred empty. Expanded it reads as a search box: an accent
-// border + 1px accent ring on focus, a subtle border at rest with a value, and a clear
-// (✕) button once there's text.
-//
-// There is no separate toggle control: the ` ` itself is the single focusable
-// element, so keyboard and screen-reader users always land on a real `searchbox` (the
-// magnifier is a decorative `aria-hidden` glyph) and pointer users focus it by clicking
-// anywhere in the box. Focus drives the open state, so the collapse/expand never adds a
-// dangling `aria-expanded` or a control that swaps roles mid-interaction.
-//
-// The open/close is animated, not an instant swap: the box stays mounted and transitions
-// its `width` between the 28px icon and the 204px field. It is pinned to the inline-end
-// of a fixed 28px wrapper and grows toward the inline-start, so it opens leftward (LTR) /
-// rightward (RTL) into the space beside it without reflowing its neighbors. `overflow-
-// hidden` clips the field while it grows, so the leading magnifier slides out with the
-// growing edge. Width, border, and fill transition together (and not at all under
-// `prefers-reduced-motion`).
-
-// Fixed collapsed footprint: the animated box is absolutely positioned within it, so
-// expanding overflows this 28px box instead of pushing siblings.
-const expandableWrapperClass = "relative inline-flex size-7 shrink-0";
-
-const expandableBoxClass = cx(
- "group/search absolute end-0 top-0 inline-flex h-7 w-7 items-center gap-2 overflow-hidden rounded-md px-1.5",
- "border-sm border-transparent bg-layer-transparent",
- "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",
- // 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",
-);
-
-export type ExpandableSearchProps = SearchProps;
-
-/**
- * A search field that collapses to a magnifier icon and expands into a full `Search`-style input
- * while focused — the toolbar/header pattern where search hides until needed. Built on Base UI
- * `Input`. The input itself is the only control (no separate toggle button), so it stays a real
- * `searchbox` for keyboard and screen-reader users; pointer users focus it by clicking the
- * magnifier. It collapses when blurred empty (or on `Escape`) and stays expanded while it has a
- * value. Drive the value with `value` + `onValueChange` (controlled) or `defaultValue`
- * (uncontrolled).
- */
-export function ExpandableSearch({
- value,
- defaultValue,
- onValueChange,
- placeholder = "Search",
- disabled,
- "aria-label": ariaLabel,
- "aria-labelledby": ariaLabelledBy,
- ...props
-}: ExpandableSearchProps) {
- 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.
- const resolvedAriaLabel = ariaLabel ?? (ariaLabelledBy ? undefined : "Search");
- // Focus is the open trigger; the field also stays open whenever it has a value, so a
- // filled field never collapses out from under its text.
- const [focused, setFocused] = React.useState(false);
- const showExpanded = focused || hasValue;
- const inputRef = React.useRef(null);
-
- return (
-
- {/* The box animates its width between the icon and the field. A `` forwards
- clicks anywhere in the box (magnifier, padding) to the input; the trailing clear
- button keeps its own onClick. It carries no text, so it adds association without
- an accessible name — the input's aria-label still names it. */}
-
-
- commit(next)}
- // Focus opens the field; blurring it empty collapses it back to the icon.
- onFocus={() => setFocused(true)}
- onBlur={() => setFocused(false)}
- // Escape clears the field, then (when already empty) blurs it shut — an explicit
- // keyboard close to mirror the pointer affordance.
- onKeyDown={(event) => {
- if (event.key === "Escape") {
- event.preventDefault();
- if (hasValue) commit("");
- else inputRef.current?.blur();
- }
- }}
- placeholder={placeholder}
- disabled={disabled}
- aria-label={resolvedAriaLabel}
- aria-labelledby={ariaLabelledBy}
- className={cx(
- "min-w-0 flex-1 bg-transparent text-14 text-primary outline-none",
- "placeholder:text-placeholder disabled:cursor-not-allowed disabled:text-disabled",
- "[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none",
- )}
- {...props}
- />
- {hasValue && !disabled ? (
- {
- commit("");
- inputRef.current?.focus();
- }}
- className={cx(
- "inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-icon-secondary outline-none",
- "transition-colors hover:bg-layer-transparent-hover",
- "focus-visible:ring-2 focus-visible:ring-accent-strong",
- )}
- >
-
-
- ) : null}
-
-
- );
-}
+// Ready-made 1:1 re-export of the ui primitives. `Search` and `ExpandableSearch` are already
+// complete single components, so there is nothing to compose. Drop down to
+// `@plane/propel/ui/search` only when you need the lower-level parts.
+export * from "../../ui/search";
diff --git a/packages/propel/src/components/search/search.stories.tsx b/packages/propel/src/components/search/search.stories.tsx
index 720008f..5a644f4 100644
--- a/packages/propel/src/components/search/search.stories.tsx
+++ b/packages/propel/src/components/search/search.stories.tsx
@@ -1,9 +1,9 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
-import * as React from "react";
-import { expect, userEvent, within } from "storybook/test";
+import { expect, userEvent } from "storybook/test";
import { ExpandableSearch, Search } from "./index";
+// Components-tier story: the ready-made `Search` / `ExpandableSearch` single components.
const meta = {
title: "Components/Search",
component: Search,
@@ -41,98 +41,23 @@ export const Disabled: Story = {
args: { defaultValue: "Product", disabled: true },
};
-/** Controlled: drive the value with `value` + `onValueChange`. */
-export const Controlled: Story = {
- parameters: { controls: { disable: true } },
- render: function ControlledStory() {
- const [value, setValue] = React.useState("");
- return (
-
-
-
Query: {value || "(empty)"}
-
- );
- },
-};
-
-/**
- * `ExpandableSearch` collapses to a magnifier icon and expands into a full search input while
- * focused — the toolbar/header pattern. The input itself is the only control, so it stays a real
- * searchbox; it collapses again when blurred empty (or on `Escape`).
- */
+/** `ExpandableSearch` collapses to a magnifier and expands into a full field while focused. */
export const Expandable: Story = {
parameters: { controls: { disable: true } },
render: () => ,
};
-/** `ExpandableSearch` with an initial value renders expanded (with the clear button). */
-export const ExpandableFilled: Story = {
- parameters: { controls: { disable: true } },
- render: () => ,
-};
-
-/**
- * `ExpandableSearch` is a single searchbox that renders collapsed and expands on focus — there is
- * no separate toggle button. Focusing it (clicking the magnifier focuses the field) expands it;
- * blurring it empty or pressing `Escape` collapses it, and a value keeps it open. Tagged out of the
- * sidebar/docs/manifest but still run under the default `test` tag.
- */
-export const ExpandAndCollapse: Story = {
- tags: ["!dev", "!autodocs", "!manifest"],
- render: () => (
-
-
- {/* A focusable sibling to blur onto, so the collapse-on-blur path is testable. */}
- elsewhere
-
- ),
- play: async ({ canvasElement }) => {
- const canvas = within(canvasElement);
-
- // The field is always a real searchbox (no separate toggle); it just renders collapsed
- // until focused. `data-expanded` on its box reflects the open state.
- const input = canvas.getByRole("searchbox", { name: "Search" });
- const box = input.closest("label");
- await expect(box).not.toHaveAttribute("data-expanded");
-
- // Focus opens it (clicking the box focuses the input); blurring it empty collapses it.
- await userEvent.click(input);
- await expect(input).toHaveFocus();
- await expect(box).toHaveAttribute("data-expanded");
- await userEvent.click(canvas.getByRole("button", { name: "elsewhere" }));
- await expect(box).not.toHaveAttribute("data-expanded");
-
- // A value keeps it open; Escape clears the field first, then (empty) collapses it.
- await userEvent.click(input);
- await userEvent.type(input, "Roadmap");
- await userEvent.keyboard("{Escape}");
- await expect(input).toHaveValue("");
- await expect(box).toHaveAttribute("data-expanded");
- await userEvent.keyboard("{Escape}");
- await expect(box).not.toHaveAttribute("data-expanded");
-
- // The field is never removed from the document — it just collapses.
- await expect(canvas.getByRole("searchbox", { name: "Search" })).toBeInTheDocument();
- },
-};
-
-/**
- * Typing reveals the clear button; clicking it empties the field and returns focus to the input.
- * Tagged out of the sidebar/docs/manifest but still run under the default `test` tag.
- */
+/** Typing reveals the clear button; clicking it empties the field and refocuses the input. */
export const TypeAndClear: Story = {
tags: ["!dev", "!autodocs", "!manifest"],
- play: async ({ canvasElement }) => {
- const canvas = within(canvasElement);
+ play: async ({ canvas }) => {
const input = canvas.getByRole("searchbox", { name: "Search" });
- // No clear button while empty.
await expect(canvas.queryByRole("button", { name: "Clear search" })).not.toBeInTheDocument();
await userEvent.type(input, "Roadmap");
await expect(input).toHaveValue("Roadmap");
- // The clear button now appears; clicking it empties the field and refocuses it.
const clear = canvas.getByRole("button", { name: "Clear search" });
await userEvent.click(clear);
await expect(input).toHaveValue("");
diff --git a/packages/propel/src/components/select/index.tsx b/packages/propel/src/components/select/index.tsx
new file mode 100644
index 0000000..7d62e06
--- /dev/null
+++ b/packages/propel/src/components/select/index.tsx
@@ -0,0 +1,22 @@
+export { SelectContent, type SelectContentProps } from "./select-content";
+// Re-export the atomic select parts so a full select can be assembled from one entry.
+export {
+ Select,
+ type SelectProps,
+ SelectIcon,
+ type SelectIconProps,
+ SelectItem,
+ type SelectItemProps,
+ SelectItemIndicator,
+ type SelectItemIndicatorProps,
+ SelectItemText,
+ SelectLabel,
+ type SelectLabelProps,
+ SelectList,
+ SelectScrollDownArrow,
+ SelectScrollUpArrow,
+ SelectTrigger,
+ type SelectTriggerProps,
+ SelectValue,
+ type SelectValueProps,
+} from "../../ui/select";
diff --git a/packages/propel/src/components/select/select-content.tsx b/packages/propel/src/components/select/select-content.tsx
new file mode 100644
index 0000000..726fae9
--- /dev/null
+++ b/packages/propel/src/components/select/select-content.tsx
@@ -0,0 +1,29 @@
+import { SelectPopup, type SelectPopupProps } from "../../ui/select/select-popup";
+import { SelectPortal } from "../../ui/select/select-portal";
+import { SelectPositioner, type SelectPositionerProps } from "../../ui/select/select-positioner";
+
+export type SelectContentProps = SelectPopupProps & {
+ /** Which side of the trigger the list opens toward. @default "bottom" */
+ side?: SelectPositionerProps["side"];
+ /** Distance in px between the trigger and the list. @default 4 */
+ sideOffset?: SelectPositionerProps["sideOffset"];
+ /** Alignment of the list relative to the trigger along `side`. @default "start" */
+ align?: SelectPositionerProps["align"];
+};
+
+/** The select list surface: portal + positioner + popup with Propel overlay styling. */
+export function SelectContent({
+ children,
+ side = "bottom",
+ sideOffset = 4,
+ align = "start",
+ ...props
+}: SelectContentProps) {
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/packages/propel/src/components/select/select.stories.tsx b/packages/propel/src/components/select/select.stories.tsx
new file mode 100644
index 0000000..90805f0
--- /dev/null
+++ b/packages/propel/src/components/select/select.stories.tsx
@@ -0,0 +1,80 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, within } from "storybook/test";
+
+import { Field, FieldError } from "../field/index";
+import {
+ Select,
+ SelectContent,
+ SelectIcon,
+ SelectItem,
+ SelectItemIndicator,
+ SelectItemText,
+ SelectLabel,
+ SelectList,
+ SelectTrigger,
+ SelectValue,
+} from "./index";
+
+const SERVER_TYPES = [
+ { label: "General purpose", value: "general" },
+ { label: "Compute optimized", value: "compute" },
+ { label: "Memory optimized", value: "memory" },
+];
+
+// Components-tier story: the ready-made `SelectContent` collapses the
+// portal/positioner/popup boilerplate into one element. The UI-tier `Select`
+// story assembles those raw parts by hand.
+const meta = {
+ title: "Components/Select",
+ component: Select,
+ subcomponents: {
+ SelectLabel,
+ SelectTrigger,
+ SelectValue,
+ SelectIcon,
+ SelectContent,
+ SelectList,
+ SelectItem,
+ SelectItemIndicator,
+ SelectItemText,
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+/** Trigger-based select using the ready-made `SelectContent` surface inside a `Field`. */
+export const Default: Story = {
+ args: { items: SERVER_TYPES, defaultValue: "general", required: true },
+ render: (args) => (
+
+
+
+ Server type
+
+
+
+
+
+
+
+ {SERVER_TYPES.map(({ label, value }) => (
+
+
+ {label}
+
+ ))}
+
+
+
+
+
+ ),
+ play: async ({ canvas, userEvent }) => {
+ await userEvent.click(canvas.getByRole("combobox", { name: "Server type" }));
+ const popup = within(document.body);
+ await expect(
+ await popup.findByRole("option", { name: "Compute optimized" }),
+ ).toBeInTheDocument();
+ },
+};
diff --git a/packages/propel/src/components/separator/index.tsx b/packages/propel/src/components/separator/index.tsx
new file mode 100644
index 0000000..cf0033f
--- /dev/null
+++ b/packages/propel/src/components/separator/index.tsx
@@ -0,0 +1,3 @@
+// Ready-made 1:1 re-export of the ui primitive. Drop down to `@plane/propel/ui/separator` only
+// when you need the lower-level parts.
+export * from "../../ui/separator";
diff --git a/packages/propel/src/components/separator/separator.stories.tsx b/packages/propel/src/components/separator/separator.stories.tsx
new file mode 100644
index 0000000..15f7387
--- /dev/null
+++ b/packages/propel/src/components/separator/separator.stories.tsx
@@ -0,0 +1,55 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect } from "storybook/test";
+
+import { Separator } from "./index";
+
+// Separator is static — a thin rule with no interaction-state styling — so it gets no
+// pseudo-states story; its only axis is `orientation`, driven by Base UI.
+const meta = {
+ title: "Components/Separator",
+ component: Separator,
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+/** The default horizontal rule, dividing stacked content. */
+export const Horizontal: Story = {
+ render: (args) => (
+
+ Above
+
+ Below
+
+ ),
+};
+
+/** `orientation="vertical"` divides inline content; Base UI sets `aria-orientation`. */
+export const Vertical: Story = {
+ render: (args) => (
+
+ Left
+
+ Right
+
+ ),
+};
+
+/**
+ * Behavior: the separator exposes `role="separator"` and reflects its orientation via
+ * `aria-orientation`. Tagged out of the sidebar/docs/manifest but still runs under `test`.
+ */
+export const HasSeparatorRole: Story = {
+ tags: ["!dev", "!autodocs", "!manifest"],
+ render: () => (
+
+
+
+
+ ),
+ play: async ({ canvas }) => {
+ const [horizontal, vertical] = canvas.getAllByRole("separator");
+ await expect(horizontal).toHaveAttribute("aria-orientation", "horizontal");
+ await expect(vertical).toHaveAttribute("aria-orientation", "vertical");
+ },
+};
diff --git a/packages/propel/src/components/slider/index.tsx b/packages/propel/src/components/slider/index.tsx
new file mode 100644
index 0000000..afd7c98
--- /dev/null
+++ b/packages/propel/src/components/slider/index.tsx
@@ -0,0 +1,15 @@
+export { Slider, type SliderProps } from "./slider";
+export {
+ SliderControl,
+ type SliderControlProps,
+ SliderIndicator,
+ type SliderIndicatorProps,
+ SliderLabel,
+ type SliderLabelProps,
+ SliderThumb,
+ type SliderThumbProps,
+ SliderTrack,
+ type SliderTrackProps,
+ SliderValue,
+ type SliderValueProps,
+} from "../../ui/slider";
diff --git a/packages/propel/src/components/slider/slider.stories.tsx b/packages/propel/src/components/slider/slider.stories.tsx
new file mode 100644
index 0000000..4d20ba8
--- /dev/null
+++ b/packages/propel/src/components/slider/slider.stories.tsx
@@ -0,0 +1,55 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect } from "storybook/test";
+
+import { Slider } from "./index";
+
+// Components-tier story: the ready-made single-thumb `Slider`. It composes the
+// `ui/slider` parts (label + value + control + track + indicator + thumb) for you —
+// pass `label` (or `aria-label`), `min`/`max`/`step`, and an optional `format`. The
+// UI-tier story shows how to assemble the parts (e.g. a two-thumb range).
+const meta = {
+ title: "Components/Slider",
+ component: Slider,
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+/** A labelled single-thumb slider. */
+export const Default: Story = {
+ args: { label: "Volume", defaultValue: 40, min: 0, max: 100, step: 1 },
+ play: async ({ canvas }) => {
+ await expect(canvas.getByRole("slider", { name: "Volume" })).toBeInTheDocument();
+ },
+};
+
+/** `format` (Intl options) controls the readout — here a percentage. */
+export const Percentage: Story = {
+ args: {
+ label: "Opacity",
+ defaultValue: 0.6,
+ min: 0,
+ max: 1,
+ step: 0.01,
+ format: { style: "percent", maximumFractionDigits: 0 },
+ },
+ play: async ({ canvas }) => {
+ await expect(canvas.getByRole("slider", { name: "Opacity" })).toBeInTheDocument();
+ },
+};
+
+/** Without a visible `label`, name the thumb with `aria-label`. */
+export const WithoutLabel: Story = {
+ parameters: { controls: { disable: true } },
+ args: { "aria-label": "Brightness", defaultValue: 50, min: 0, max: 100, step: 1 },
+ play: async ({ canvas }) => {
+ await expect(canvas.getByRole("slider", { name: "Brightness" })).toBeInTheDocument();
+ },
+};
diff --git a/packages/propel/src/components/slider/slider.tsx b/packages/propel/src/components/slider/slider.tsx
new file mode 100644
index 0000000..46b6eef
--- /dev/null
+++ b/packages/propel/src/components/slider/slider.tsx
@@ -0,0 +1,47 @@
+import type * as React from "react";
+
+import {
+ Slider as SliderRoot,
+ SliderControl,
+ SliderIndicator,
+ SliderLabel,
+ type SliderProps as SliderRootProps,
+ SliderThumb,
+ SliderTrack,
+ SliderValue,
+} from "../../ui/slider";
+
+export type SliderProps = SliderRootProps & {
+ /**
+ * Optional visible label rendered above the track (e.g. "Volume"). When omitted, provide an
+ * `aria-label` for the thumb so the control is still named for assistive tech.
+ */
+ label?: React.ReactNode;
+ /** Accessible name for the thumb. Required when there is no visible `label`. */
+ "aria-label"?: string;
+};
+
+/**
+ * The ready-made slider: a single-thumb control for picking a number within a range. Drive it with
+ * `value`/`defaultValue` + `onValueChange`, and bound it with `min`/`max`/`step`. Pass `label`
+ * (visible) or `aria-label` to name the thumb, and `format` (an `Intl.NumberFormatOptions`) to
+ * format the readout.
+ *
+ * Composed from the `ui/slider` parts (`Slider` root + `SliderLabel` + `SliderValue` +
+ * `SliderControl` + `SliderTrack` + `SliderIndicator` + `SliderThumb`), which are built on Base UI
+ * `Slider`. For multiple thumbs or fully custom layout, compose the `ui/slider` parts directly.
+ */
+export function Slider({ label, "aria-label": ariaLabel, ...props }: SliderProps) {
+ return (
+
+ {label == null ? null : {label} }
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/propel/src/components/switch/index.tsx b/packages/propel/src/components/switch/index.tsx
index 11fbbce..346990f 100644
--- a/packages/propel/src/components/switch/index.tsx
+++ b/packages/propel/src/components/switch/index.tsx
@@ -1,69 +1,3 @@
-import { Switch as BaseSwitch } from "@base-ui/react/switch";
-import { cva, cx, type VariantProps } from "class-variance-authority";
-import type * as React from "react";
-
-// Magnitudes follow the Figma "Toggle" Size scale (track w×h, px):
-// L 30×18 → lg · M 27×16 → md · S 23×14 → sm.
-// The thumb is the track height minus a 1px gap top/bottom; on/off it travels
-// from the 1px gutter on one side to the 1px gutter on the other.
-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",
- // 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",
- "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-strong",
- ),
- {
- variants: {
- magnitude: {
- lg: "h-[18px] w-[30px]",
- md: "h-4 w-[27px]",
- sm: "h-[14px] w-[23px]",
- },
- },
- },
-);
-
-// Thumb diameter per magnitude (track height − 2px gap) and the travel distance
-// when checked (track width − thumb − 2px of gutters).
-// 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("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]",
- },
- },
-});
-
-export type SwitchMagnitude = NonNullable["magnitude"]>;
-
-export type SwitchProps = Omit<
- React.ComponentProps,
- "className" | "render" | "style"
-> & {
- /** Track + thumb size, from the Figma "Toggle" Size scale: `lg` 30×18, `md` 27×16, `sm` 23×14. */
- magnitude: SwitchMagnitude;
-};
-
-/**
- * A switch toggles a single setting on or off. Built on Base UI's `Switch` (so it carries
- * `role="switch"` and full keyboard/form support). Maps to Figma's "Toggle" component.
- *
- * On/off, `disabled`, and `readOnly` are control state from the primitive, not variants — pass them
- * as props (`checked`/`defaultChecked`, `disabled`, `readOnly`). Only the size axis (`magnitude`)
- * is a visual variant.
- */
-export function Switch({ magnitude, ...props }: SwitchProps) {
- return (
-
-
-
- );
-}
+export { Switch, type SwitchMagnitude, type SwitchProps } from "./switch";
+// Re-export the atomic parts so the full switch surface is importable from this convenience.
+export { Switch as SwitchTrack, SwitchThumb, type SwitchThumbProps } from "../../ui/switch";
diff --git a/packages/propel/src/components/switch/switch.tsx b/packages/propel/src/components/switch/switch.tsx
new file mode 100644
index 0000000..efe6f27
--- /dev/null
+++ b/packages/propel/src/components/switch/switch.tsx
@@ -0,0 +1,26 @@
+import {
+ Switch as SwitchRoot,
+ type SwitchProps as SwitchRootProps,
+ SwitchThumb,
+} from "../../ui/switch";
+
+export type { SwitchMagnitude } from "../../ui/switch";
+
+export type SwitchProps = SwitchRootProps;
+
+/**
+ * The ready-made switch: composes the atomic `Switch` track with its `SwitchThumb`, threading the
+ * required `magnitude` through to both. Built on Base UI's `Switch` (so it carries `role="switch"`
+ * and full keyboard/form support). Maps to Figma's "Toggle" component.
+ *
+ * On/off, `disabled`, and `readOnly` are control state from the primitive, not variants — pass them
+ * as props (`checked`/`defaultChecked`, `disabled`, `readOnly`). Only the size axis (`magnitude`)
+ * is a visual variant.
+ */
+export function Switch({ magnitude, ...props }: SwitchProps) {
+ return (
+
+
+
+ );
+}
diff --git a/packages/propel/src/components/table/index.tsx b/packages/propel/src/components/table/index.tsx
index cd186ec..58da758 100644
--- a/packages/propel/src/components/table/index.tsx
+++ b/packages/propel/src/components/table/index.tsx
@@ -1,482 +1,19 @@
-import { ScrollArea as BaseScrollArea } from "@base-ui/react/scroll-area";
-import { cva, cx, type VariantProps } from "class-variance-authority";
-import { ChevronDown, ChevronsUpDown, ChevronUp, Ellipsis } from "lucide-react";
-import * as React from "react";
-
-import { scrollbarClass, scrollbarThumbClass } from "../../internal/scrollbar";
-import { Dropdown, DropdownTrigger } from "../dropdown/index";
-
-// Figma "Table" node 5196-4084 ships two layouts that share the same cell metrics
-// (38px header / 44px body, px-4 py-2, header on `background/layer/1`, rows on
-// `background/layer/2`) and differ only in their dividers:
-// • `table` — row dividers only (header underline + a hairline under each
-// body row); no vertical rules.
-// • `spreadsheet` — a full grid: every header and body cell draws a 0.5px
-// `border/subtle` on all sides.
-// The whole table sits inside a rounded, `border/subtle`-bordered, scrollable frame.
-// The variant is owned by the root and read by the cells through context, so a
-// consumer only sets it once on ``.
-
-export type TableVariant = "table" | "spreadsheet";
-
-// Which edge a cell is pinned to while the table scrolls horizontally (the "make the
-// first / last column sticky" option). `start` sticks to the inline-start edge, `end`
-// to the inline-end edge. Set it on a column's header AND its body cells.
-export type TablePinned = "start" | "end";
-
-const TableVariantContext = React.createContext("table");
-
-export type TableProps = Omit, "className" | "style"> & {
- /**
- * Layout (required). `table` draws row dividers only; `spreadsheet` draws a full grid (every cell
- * bordered on all sides). Both share the same cell metrics.
- */
- variant: TableVariant;
-};
-
-/**
- * Root ``, wrapped in a rounded, hairline-bordered scroll frame. Compose with `TableHeader`,
- * `TableBody`, `TableRow`, `TableHead`, `TableCell`, `TableEditableCell`, and `TableActionCell`.
- * The frame scrolls when the table overflows it (constrain its parent's height to scroll
- * vertically); the header stays pinned to the top, and columns can be pinned with each cell's
- * `pinned` prop.
- *
- * Pass `variant="table"` for row dividers or `variant="spreadsheet"` for a full grid; the cells
- * read it via context.
- */
-export function Table({ variant, ...props }: TableProps) {
- return (
-
- {/* The scroll frame is a Base UI ScrollArea: its Viewport is a real scroll
- container (so sticky headers + pinned columns work relative to it) and its
- scrollbar is an OVERLAY thumb positioned absolutely — it never reserves a
- gutter, so the header isn't clipped and the table width stays constant whether
- or not the scrollbar shows. The Root owns the outer `border/subtle`, the
- `radius/lg` corners, and clips the overflow (a `` can't clip its own
- rounded corners). `max-h-full` caps it at a height-constrained parent so it
- scrolls instead of growing. Base UI gives the Viewport `tabIndex=0` only while
- it overflows, satisfying axe `scrollable-region-focusable` without a stray tab
- stop when the table fits. */}
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-export type TableHeaderProps = Omit, "className" | "style">;
-
-/** Header section (``). Holds a single `TableRow` of `TableHead` cells. */
-export function TableHeader(props: TableHeaderProps) {
- return ;
-}
-
-export type TableBodyProps = Omit, "className" | "style">;
-
-/** Body section (``). Holds the data `TableRow`s. */
-export function TableBody(props: TableBodyProps) {
- return ;
-}
-
-export type TableRowProps = Omit, "className" | "style">;
-
-/**
- * A table row (``). Body rows sit on `layer-2` and tint to `layer-2-hover` on hover. Dividers
- * are drawn per-cell (so the `spreadsheet` grid works), so the row itself carries only the
- * background + hover.
- */
-export function TableRow(props: TableRowProps) {
- // `group/body-row` so a `table`-variant cell can drop its bottom divider on the last
- // row (`group-last/body-row:border-b-0`) and a pinned cell can follow the row's hover.
- return ;
-}
-
-// Per-variant cell borders. The scroll frame already draws the single outer
-// `border/subtle` ring (and rounds the corners), so cells only ever draw INTERIOR
-// dividers — drawing a full border on the edge cells too would double the frame's ring
-// (a visible 1px-ish double line that squared off the rounded corners).
-//
-// In `table`, only a bottom hairline divides rows (header uses the 1px `border/sm`,
-// body cells the 0.5px `border/xs`). In `spreadsheet`, a full grid: each cell adds an
-// inline-end divider (logical `border-e`, RTL-safe) plus the bottom hairline; the last
-// column drops its end divider (`last:border-e-0`) and the last row drops its bottom
-// divider so the frame's ring stays single all the way around.
-const headBorder: Record = {
- table: "border-b border-subtle",
- spreadsheet: "border-b-[0.5px] border-e-[0.5px] border-subtle last:border-e-0",
-};
-const cellBorder: Record = {
- // Last body row drops its bottom divider so the rounded table closes cleanly.
- table: "border-b-[0.5px] border-subtle group-last/body-row:border-b-0",
- spreadsheet:
- "border-b-[0.5px] border-e-[0.5px] border-subtle last:border-e-0 group-last/body-row:border-b-0",
-};
-
-// Sticky-column pinning. A pinned cell sticks to the inline-start/end edge while the
-// table scrolls sideways. A `start` column draws a hairline on its inline-end edge and
-// an `end` column on its inline-start edge (logical, RTL-safe) so the pinned column
-// reads as separated from the content scrolling under it. Body cells get an opaque
-// background (matching the row, incl. its hover) so scrolled columns slide beneath them;
-// the header's pinned cell sits above both the sticky header row (z-20) and the pinned
-// body column (z-10).
-function pinnedEdgeBorder(pinned: TablePinned) {
- return pinned === "start" ? "border-e-[0.5px] border-subtle" : "border-s-[0.5px] border-subtle";
-}
-function pinnedHeadClass(pinned: TablePinned | undefined) {
- if (!pinned) return "z-20";
- return cx("sticky z-30", pinned === "start" ? "start-0" : "end-0", pinnedEdgeBorder(pinned));
-}
-function pinnedCellClass(pinned: TablePinned | undefined) {
- if (!pinned) return "";
- return cx(
- "sticky z-10 bg-layer-2 group-hover/body-row:bg-layer-2-hover",
- pinned === "start" ? "start-0" : "end-0",
- pinnedEdgeBorder(pinned),
- );
-}
-
-// Header cells follow the Figma "Table header" component: 38px tall, `px-4 py-2`,
-// `text-12` semibold on `background/layer/1` in `text/tertiary` (the muted header
-// token from Figma). `sticky top-0` keeps the header in view while the body scrolls
-// (the `layer-1` fill is opaque, so rows pass under it).
-const tableHeadVariants = cva(
- "sticky top-0 h-[38px] px-4 py-2 text-start align-middle text-12 font-semibold text-tertiary",
- {
- variants: {
- variant: {
- default: "bg-layer-1",
- sortable: "bg-layer-1",
- },
- },
- },
-);
-
-/** Sort direction of a sortable `TableHead`. `none` shows the neutral affordance. */
-export type TableHeadSort = "asc" | "desc" | "none";
-
-const sortIcon: Record = {
- asc: ChevronUp,
- desc: ChevronDown,
- none: ChevronsUpDown,
-};
-
-const ariaSort: Record = {
- asc: "ascending",
- desc: "descending",
- none: "none",
-};
-
-export type TableHeadProps = Omit, "className" | "style" | "aria-sort"> &
- VariantProps & {
- /**
- * Visual treatment. `sortable` renders a clickable button with a sort chevron; set it whenever
- * you pass `sort`.
- */
- variant: NonNullable["variant"]>;
- /**
- * Current sort state for a sortable header. Drives both the chevron icon (`asc`→up,
- * `desc`→down, `none`→up/down) and the cell's `aria-sort`.
- */
- sort?: TableHeadSort;
- /** Click handler for the sort control; only used when the header is sortable. */
- onSort?: () => void;
- /** Pin this header to the inline-start/end edge when the table scrolls sideways. */
- pinned?: TablePinned;
- };
-
-/**
- * A header cell (``). Pass `variant="sortable"` (with `sort`/`onSort`) to turn the label into
- * an interactive sort control: it renders a lucide chevron (aria-hidden) and reflects the order via
- * `aria-sort` for assistive tech. Pass `pinned` to make the column sticky on horizontal scroll (set
- * it on the body cells too).
- */
-export function TableHead({
- variant,
- sort = "none",
- onSort,
- pinned,
- children,
- ...props
-}: TableHeadProps) {
- const tableVariant = React.useContext(TableVariantContext);
- const isSortable = variant === "sortable";
- // Only render the interactive sort control when there's a handler to drive it;
- // a sortable-styled header without `onSort` falls back to a plain label so we
- // never expose a focusable button that does nothing.
- const hasSortControl = isSortable && onSort != null;
- const SortGlyph = sortIcon[sort];
- return (
-
- {hasSortControl ? (
-
- {children}
-
-
- ) : (
- {children}
- )}
-
- );
-}
-
-// Keeps the chevron a fixed 14px (Figma sort icon) with the muted icon color.
-function ChevronGlyphSlot({ Glyph }: { Glyph: typeof ChevronsUpDown }) {
- return ;
-}
-
-// Shared `` chrome for the plain, editable, and action cells: 44px tall,
-// `align-middle`, plus the per-variant border read from context. Padding is
-// intentionally NOT included here: the plain cell adds `px-4 py-2`, while the editable
-// and action cells stay `p-0` so their full-bleed button supplies the inset. (Baking
-// padding in and overriding it with `p-0` is order-fragile under `cx`, which is what
-// made editable rows render too tall.)
-function useTableCellClass() {
- const tableVariant = React.useContext(TableVariantContext);
- return cx("h-11 align-middle", cellBorder[tableVariant]);
-}
-
-// A leading/trailing slot inside a cell — an icon or an avatar that sits beside the
-// cell's content. It never shrinks and centers its node; the node sizes itself.
-function TableCellSlot({ children }: { children: React.ReactNode }) {
- return {children} ;
-}
-
-export type TableCellProps = Omit, "className" | "style"> & {
- /** 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`. */
- inlineEndNode?: React.ReactNode;
- /** Pin this cell to the inline-start/end edge when the table scrolls sideways. */
- pinned?: TablePinned;
-};
-
-/**
- * A data cell (``). 44px tall with `px-4 py-2` and `text-13` body text. Optional
- * `inlineStartNode` / `inlineEndNode` slots hold a leading/trailing icon or `Avatar` (e.g. an
- * avatar before a name). `pinned` keeps the column sticky on horizontal scroll.
- */
-export function TableCell({
- inlineStartNode,
- inlineEndNode,
- pinned,
- children,
- ...props
-}: TableCellProps) {
- const className = useTableCellClass();
- return (
-
-
- {inlineStartNode != null ?