` 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 `