Skip to content
Open
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
114 changes: 114 additions & 0 deletions src/lib/form/custom-radio.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import React, { ReactNode } from "react";
import {
Radio as AriaRadio,
RadioGroup as AriaRadioGroup,
FieldError,
type FieldErrorProps,
Label,
type RadioGroupProps as AriaRadioGroupProps,
type RadioProps as AriaRadioProps,
type RadioRenderProps,
} from "react-aria-components";
import { cn } from "../../utils";

export { RadioIndicator } from "./radio-indicator";

interface CustomRadioItemProps extends Omit<AriaRadioProps, "className"> {
className?: string;
}

/** A single radio whose content is fully owned by the caller. Use the render-prop
* `children` to receive `{ isSelected, isHovered, isFocusVisible, ... }` and compose your
* own UI (cards, labels, ...), placing a `<RadioIndicator>` where you want it. Renders a
* `<label>`, so never nest interactive controls inside it — render adornments (a help
* tooltip) and conditional content (a field) as siblings within the `<CustomRadio>`. */
export function CustomRadioItem({
className,
children,
...props
}: Readonly<CustomRadioItemProps>) {
return (
<AriaRadio
{...props}
className={cn(
"relative box-border block cursor-pointer",
"text-klerosUIComponentsPrimaryText disabled:text-klerosUIComponentsStroke disabled:cursor-default",
className,
)}
>
{children}
</AriaRadio>
);
}

export interface CustomRadioOption
extends Omit<AriaRadioProps, "children" | "className"> {
/** Custom content for this option. A function receives the react-aria render props. */
content: ReactNode | ((renderProps: RadioRenderProps) => ReactNode);
className?: string;
}

export interface CustomRadioProps
extends Omit<AriaRadioGroupProps, "children" | "className"> {
/** Convenience API for simple option lists. For per-row adornments or content
* interleaved between options, use `children` (compose `<CustomRadioItem>`s) instead. */
items?: CustomRadioOption[];
children?: ReactNode;
/** Group label rendered above the options. */
groupLabel?: string;
className?: string;
/** Props for field error display.
* [See FieldErrorProps](https://react-spectrum.adobe.com/react-aria/RadioGroup.html#fielderror) */
fieldErrorProps?: FieldErrorProps;
}

/** A radio group whose options render arbitrary content (cards, tooltip-wrapped labels, ...)
* while keeping react-aria's `RadioGroup` semantics (single-select, roving tab-index,
* keyboard navigation, `role="radiogroup"`). Pass `items` for simple lists, or `children`
* (compose `<CustomRadioItem>`s, with adornments/fields as siblings) for richer layouts.
* For the plain label-only case, use `Radio` (the `options`-based group) instead.
* [Extends AriaRadioGroupProps](https://react-spectrum.adobe.com/react-aria/RadioGroup.html#radiogroup-1) */
function CustomRadio({
groupLabel,
className,
fieldErrorProps,
items,
children,
...props
}: Readonly<CustomRadioProps>) {
return (
<AriaRadioGroup
{...props}
className={cn(
"relative flex flex-col gap-2",
"orientation-horizontal:flex-row orientation-horizontal:items-center orientation-horizontal:gap-4",
className,
)}
>
{groupLabel && (
<Label className="text-klerosUIComponentsSecondaryText text-base">
{groupLabel}
</Label>
)}
{children ??
items?.map(({ content, className: itemClassName, ...item }) => (
<CustomRadioItem
key={String(item.value)}
{...item}
className={itemClassName}
>
{content}
</CustomRadioItem>
))}
<FieldError
{...fieldErrorProps}
className={cn(
"text-klerosUIComponentsError self-end text-sm",
fieldErrorProps?.className,
)}
/>
</AriaRadioGroup>
);
}

export default CustomRadio;
28 changes: 6 additions & 22 deletions src/lib/form/radio-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from "react-aria-components";
import { cn } from "../../utils";
import clsx from "clsx";
import { RadioIndicator } from "./radio-indicator";

interface RadioOption extends AriaRadioProps {
label: string;
Expand Down Expand Up @@ -70,29 +71,12 @@ function RadioGroup({
option.className,
)}
>
{({ isSelected, isHovered, isDisabled, isPressed }) => (
{(renderProps) => (
<>
<span
className={cn(
"border-klerosUIComponentsStroke absolute top-1 left-0 rounded-full border",
"after:bg-klerosUIComponentsPrimaryBlue after:absolute after:hidden after:rounded-full",
"ease-ease after:ease-ease transition-all after:transition-all",
small
? "size-4 after:top-0.75 after:left-0.75 after:size-2"
: "size-6 after:top-1.25 after:left-1.25 after:size-3",
isSelected && [
"bg-klerosUIComponentsWhiteBackground border-klerosUIComponentsPrimaryBlue after:block",
],
isHovered && [
"border-klerosUIComponentsSecondaryBlue bg-klerosUIComponentsLightBlue",
],
isPressed && [
"border-klerosUIComponentsSecondaryBlue after:bg-klerosUIComponentsSecondaryBlue",
],
isDisabled && [
"border-klerosUIComponentsStroke after:hidden",
],
)}
<RadioIndicator
{...renderProps}
small={small}
className="absolute top-1 left-0"
/>
{option.label}
</>
Expand Down
56 changes: 56 additions & 0 deletions src/lib/form/radio-indicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React from "react";
import { type RadioRenderProps } from "react-aria-components";
import { cn } from "../../utils";

interface RadioIndicatorProps extends Partial<RadioRenderProps> {
small?: boolean;
/** Show the focus-visible outline on the circle itself (default). Set `false` when the
* surrounding option already renders its own focus ring (e.g. a card whose whole surface
* is the target), so you don't get a ring on both the card and the circle. */
focusRing?: boolean;
className?: string;
}

/** The standard radio circle, decoupled from layout so a caller can place it anywhere
* (e.g. on the right of a card, or absolutely positioned beside a label). Spread the
* react-aria render props (`isSelected`, `isHovered`, `isFocusVisible`, ...) onto it to
* drive its state. Shared by `Radio` (the plain group) and `CustomRadio`. */
export function RadioIndicator({
small,
isSelected,
isHovered,
isPressed,
isFocusVisible,
isDisabled,
focusRing = true,
className,
}: Readonly<RadioIndicatorProps>) {
return (
<span
aria-hidden
className={cn(
"border-klerosUIComponentsStroke relative box-border inline-block shrink-0 rounded-full border",
"after:bg-klerosUIComponentsPrimaryBlue after:absolute after:hidden after:rounded-full",
"ease-ease after:ease-ease transition-all after:transition-all",
Comment thread
kemuru marked this conversation as resolved.
small
? "size-4 after:top-0.75 after:left-0.75 after:size-2"
: "size-6 after:top-1.25 after:left-1.25 after:size-3",
isSelected && [
"bg-klerosUIComponentsWhiteBackground border-klerosUIComponentsPrimaryBlue after:block",
],
isHovered && [
"border-klerosUIComponentsSecondaryBlue bg-klerosUIComponentsLightBlue",
],
isPressed && [
"border-klerosUIComponentsSecondaryBlue after:bg-klerosUIComponentsSecondaryBlue",
],
isFocusVisible &&
focusRing && [
"outline-klerosUIComponentsPrimaryBlue outline-2 outline-offset-2",
],
isDisabled && ["border-klerosUIComponentsStroke after:hidden"],
className,
)}
/>
);
}
5 changes: 5 additions & 0 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export { default as FileUploader } from "./form/file-uploader";
export { default as Datepicker } from "./form/datepicker";

export { default as Radio } from "./form/radio-group";
export {
default as CustomRadio,
CustomRadioItem,
RadioIndicator,
} from "./form/custom-radio";
export { default as Checkbox } from "../lib/form/checkbox";
export { default as Switch } from "../lib/form/switch";

Expand Down
113 changes: 113 additions & 0 deletions src/stories/customRadio.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import React, { Fragment, useState } from "react";
import type { Meta, StoryObj } from "@storybook/react";
import type { RadioRenderProps } from "react-aria-components";

import { IPreviewArgs } from "./utils";

import CustomRadio, {
CustomRadioItem,
RadioIndicator,
type CustomRadioOption,
} from "../lib/form/custom-radio";
import Card from "../lib/container/card";
import TextField from "../lib/form/text-field";
import { cn } from "../utils";

const meta = {
component: CustomRadio,
title: "Input/CustomRadio",
tags: ["autodocs"],
} satisfies Meta<typeof CustomRadio>;

export default meta;

type Story = StoryObj<typeof meta> & IPreviewArgs;

/** A full-card option with the indicator on the right; the whole card is the click target
* (it is the radio's `<label>`). Because the card is the target, the focus ring and selected
* emphasis go on the card (driven by the render props), not on the small circle — so
* `RadioIndicator` gets `focusRing={false}` to avoid a double ring. Defined at module scope
* (not inside the story's `render`) so it keeps a stable identity across renders. */
const CreationMethodCard = ({
title,
...rp
}: RadioRenderProps & { title: string }) => (
<Card
hover
className={cn(
"flex h-fit w-[420px] items-center gap-4 p-4",
rp.isSelected && "border-klerosUIComponentsPrimaryBlue",
rp.isFocusVisible &&
"ring-klerosUIComponentsPrimaryBlue ring-2 ring-offset-2",
)}
>
<span className="text-klerosUIComponentsPrimaryText grow text-base">
{title}
</span>
<RadioIndicator {...rp} focusRing={false} />
</Card>
);

const CREATION_METHOD_ITEMS: CustomRadioOption[] = [
{ value: "scratch", title: "Create a case from scratch" },
{ value: "duplicate", title: "Duplicate an existing case" },
].map(({ value, title }) => ({
value,
content: (rp) => <CreationMethodCard title={title} {...rp} />,
}));

/** `items` API — the simplest case. This mirrors react-aria's own card-radio example. */
export const Cards: Story = {
args: { themeUI: "dark", backgroundUI: "light" },
render: function Render() {
const [value, setValue] = useState("scratch");
return (
<CustomRadio
aria-label="Creation method"
value={value}
onChange={setValue}
items={CREATION_METHOD_ITEMS}
/>
);
},
};

/** Composition API (`children` + `<CustomRadioItem>`). Use this when options need
* per-row adornments or interleaved content that the flat `items` array can't express.
* Here a conditional `<TextField>` is rendered as a sibling of the selected option —
* it must NOT go inside the radio's `<label>` (interactive controls there are invalid
* and would toggle the radio). The `RadioIndicator` is driven by the item's render props. */
export const Composition: Story = {
args: { themeUI: "dark", backgroundUI: "light" },
render: function Render() {
const [value, setValue] = useState("all");
const options = [
{ value: "all", label: "All jurors in the court" },
{ value: "gated", label: "Jurors owning a specific ERC-20" },
];
return (
<CustomRadio
aria-label="Eligibility"
groupLabel="Eligibility"
value={value}
onChange={setValue}
>
{options.map(({ value: v, label }) => (
<Fragment key={v}>
<CustomRadioItem value={v}>
{(rp) => (
<span className="flex items-center gap-2">
<RadioIndicator {...rp} small />
{label}
</span>
)}
</CustomRadioItem>
{v === "gated" && value === "gated" ? (
<TextField aria-label="Token address" placeholder="0x..." />
) : null}
</Fragment>
))}
</CustomRadio>
);
},
};
Loading