Skip to content

Commit a15f8e1

Browse files
committed
feat: custom-radio
1 parent 407e2f3 commit a15f8e1

5 files changed

Lines changed: 285 additions & 22 deletions

File tree

src/lib/form/custom-radio.tsx

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import React, { ReactNode } from "react";
2+
import {
3+
Radio as AriaRadio,
4+
RadioGroup as AriaRadioGroup,
5+
FieldError,
6+
type FieldErrorProps,
7+
Label,
8+
type RadioGroupProps as AriaRadioGroupProps,
9+
type RadioProps as AriaRadioProps,
10+
type RadioRenderProps,
11+
} from "react-aria-components";
12+
import { cn } from "../../utils";
13+
import { RadioIndicator } from "./radio-indicator";
14+
15+
export { RadioIndicator };
16+
17+
interface CustomRadioItemProps extends Omit<AriaRadioProps, "className"> {
18+
className?: string;
19+
}
20+
21+
/** A single radio whose content is fully owned by the caller. Use the render-prop
22+
* `children` to receive `{ isSelected, isHovered, isFocusVisible, ... }` and compose your
23+
* own UI (cards, labels, ...), placing a `<RadioIndicator>` where you want it. Renders a
24+
* `<label>`, so never nest interactive controls inside it — render adornments (a help
25+
* tooltip) and conditional content (a field) as siblings within the `<CustomRadio>`. */
26+
export function CustomRadioItem({
27+
className,
28+
children,
29+
...props
30+
}: Readonly<CustomRadioItemProps>) {
31+
return (
32+
<AriaRadio
33+
{...props}
34+
className={cn(
35+
"relative box-border block cursor-pointer",
36+
"text-klerosUIComponentsPrimaryText disabled:text-klerosUIComponentsStroke disabled:cursor-default",
37+
className,
38+
)}
39+
>
40+
{children}
41+
</AriaRadio>
42+
);
43+
}
44+
45+
export interface CustomRadioOption
46+
extends Omit<AriaRadioProps, "children" | "className"> {
47+
/** Custom content for this option. A function receives the react-aria render props. */
48+
content: ReactNode | ((renderProps: RadioRenderProps) => ReactNode);
49+
className?: string;
50+
}
51+
52+
export interface CustomRadioProps
53+
extends Omit<AriaRadioGroupProps, "children" | "className"> {
54+
/** Convenience API for simple option lists. For per-row adornments or content
55+
* interleaved between options, use `children` (compose `<CustomRadioItem>`s) instead. */
56+
items?: CustomRadioOption[];
57+
children?: ReactNode;
58+
/** Group label rendered above the options. */
59+
groupLabel?: string;
60+
className?: string;
61+
/** Props for field error display.
62+
* [See FieldErrorProps](https://react-spectrum.adobe.com/react-aria/RadioGroup.html#fielderror) */
63+
fieldErrorProps?: FieldErrorProps;
64+
}
65+
66+
/** A radio group whose options render arbitrary content (cards, tooltip-wrapped labels, ...)
67+
* while keeping react-aria's `RadioGroup` semantics (single-select, roving tab-index,
68+
* keyboard navigation, `role="radiogroup"`). Pass `items` for simple lists, or `children`
69+
* (compose `<CustomRadioItem>`s, with adornments/fields as siblings) for richer layouts.
70+
* For the plain label-only case, use `Radio` (the `options`-based group) instead.
71+
* [Extends AriaRadioGroupProps](https://react-spectrum.adobe.com/react-aria/RadioGroup.html#radiogroup-1) */
72+
function CustomRadio({
73+
groupLabel,
74+
className,
75+
fieldErrorProps,
76+
items,
77+
children,
78+
...props
79+
}: Readonly<CustomRadioProps>) {
80+
return (
81+
<AriaRadioGroup
82+
{...props}
83+
className={cn(
84+
"relative flex flex-col gap-2",
85+
"orientation-horizontal:flex-row orientation-horizontal:items-center orientation-horizontal:gap-4",
86+
className,
87+
)}
88+
>
89+
{groupLabel && (
90+
<Label className="text-klerosUIComponentsSecondaryText text-base">
91+
{groupLabel}
92+
</Label>
93+
)}
94+
{children ??
95+
items?.map(({ content, className: itemClassName, ...item }) => (
96+
<CustomRadioItem
97+
key={String(item.value)}
98+
{...item}
99+
className={itemClassName}
100+
>
101+
{content}
102+
</CustomRadioItem>
103+
))}
104+
<FieldError
105+
{...fieldErrorProps}
106+
className={cn(
107+
"text-klerosUIComponentsError self-end text-sm",
108+
fieldErrorProps?.className,
109+
)}
110+
/>
111+
</AriaRadioGroup>
112+
);
113+
}
114+
115+
export default CustomRadio;

src/lib/form/radio-group.tsx

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from "react-aria-components";
1111
import { cn } from "../../utils";
1212
import clsx from "clsx";
13+
import { RadioIndicator } from "./radio-indicator";
1314

1415
interface RadioOption extends AriaRadioProps {
1516
label: string;
@@ -70,29 +71,12 @@ function RadioGroup({
7071
option.className,
7172
)}
7273
>
73-
{({ isSelected, isHovered, isDisabled, isPressed }) => (
74+
{(renderProps) => (
7475
<>
75-
<span
76-
className={cn(
77-
"border-klerosUIComponentsStroke absolute top-1 left-0 rounded-full border",
78-
"after:bg-klerosUIComponentsPrimaryBlue after:absolute after:hidden after:rounded-full",
79-
"ease-ease after:ease-ease transition-all after:transition-all",
80-
small
81-
? "size-4 after:top-0.75 after:left-0.75 after:size-2"
82-
: "size-6 after:top-1.25 after:left-1.25 after:size-3",
83-
isSelected && [
84-
"bg-klerosUIComponentsWhiteBackground border-klerosUIComponentsPrimaryBlue after:block",
85-
],
86-
isHovered && [
87-
"border-klerosUIComponentsSecondaryBlue bg-klerosUIComponentsLightBlue",
88-
],
89-
isPressed && [
90-
"border-klerosUIComponentsSecondaryBlue after:bg-klerosUIComponentsSecondaryBlue",
91-
],
92-
isDisabled && [
93-
"border-klerosUIComponentsStroke after:hidden",
94-
],
95-
)}
76+
<RadioIndicator
77+
{...renderProps}
78+
small={small}
79+
className="absolute top-1 left-0"
9680
/>
9781
{option.label}
9882
</>

src/lib/form/radio-indicator.tsx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import React from "react";
2+
import { type RadioRenderProps } from "react-aria-components";
3+
import { cn } from "../../utils";
4+
5+
interface RadioIndicatorProps extends Partial<RadioRenderProps> {
6+
small?: boolean;
7+
/** Show the focus-visible outline on the circle itself (default). Set `false` when the
8+
* surrounding option already renders its own focus ring (e.g. a card whose whole surface
9+
* is the target), so you don't get a ring on both the card and the circle. */
10+
focusRing?: boolean;
11+
className?: string;
12+
}
13+
14+
/** The standard radio circle, decoupled from layout so a caller can place it anywhere
15+
* (e.g. on the right of a card, or absolutely positioned beside a label). Spread the
16+
* react-aria render props (`isSelected`, `isHovered`, `isFocusVisible`, ...) onto it to
17+
* drive its state. Shared by `Radio` (the plain group) and `CustomRadio`. */
18+
export function RadioIndicator({
19+
small,
20+
isSelected,
21+
isHovered,
22+
isPressed,
23+
isFocusVisible,
24+
isDisabled,
25+
focusRing = true,
26+
className,
27+
}: Readonly<RadioIndicatorProps>) {
28+
return (
29+
<span
30+
aria-hidden
31+
className={cn(
32+
"border-klerosUIComponentsStroke relative box-border inline-block shrink-0 rounded-full border",
33+
"after:bg-klerosUIComponentsPrimaryBlue after:absolute after:hidden after:rounded-full",
34+
"ease-ease after:ease-ease transition-all after:transition-all",
35+
small
36+
? "size-4 after:top-0.75 after:left-0.75 after:size-2"
37+
: "size-6 after:top-1.25 after:left-1.25 after:size-3",
38+
isSelected && [
39+
"bg-klerosUIComponentsWhiteBackground border-klerosUIComponentsPrimaryBlue after:block",
40+
],
41+
isHovered && [
42+
"border-klerosUIComponentsSecondaryBlue bg-klerosUIComponentsLightBlue",
43+
],
44+
isPressed && [
45+
"border-klerosUIComponentsSecondaryBlue after:bg-klerosUIComponentsSecondaryBlue",
46+
],
47+
isFocusVisible &&
48+
focusRing && [
49+
"outline-klerosUIComponentsPrimaryBlue outline-2 outline-offset-2",
50+
],
51+
isDisabled && ["border-klerosUIComponentsStroke after:hidden"],
52+
className,
53+
)}
54+
/>
55+
);
56+
}

src/lib/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ export { default as FileUploader } from "./form/file-uploader";
2626
export { default as Datepicker } from "./form/datepicker";
2727

2828
export { default as Radio } from "./form/radio-group";
29+
export {
30+
default as CustomRadio,
31+
CustomRadioItem,
32+
RadioIndicator,
33+
} from "./form/custom-radio";
2934
export { default as Checkbox } from "../lib/form/checkbox";
3035
export { default as Switch } from "../lib/form/switch";
3136

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import React, { Fragment, useState } from "react";
2+
import type { Meta, StoryObj } from "@storybook/react";
3+
4+
import { IPreviewArgs } from "./utils";
5+
6+
import CustomRadio, {
7+
CustomRadioItem,
8+
RadioIndicator,
9+
} from "../lib/form/custom-radio";
10+
import Card from "../lib/container/card";
11+
import TextField from "../lib/form/text-field";
12+
import { cn } from "../utils";
13+
14+
const meta = {
15+
component: CustomRadio,
16+
title: "Input/CustomRadio",
17+
tags: ["autodocs"],
18+
} satisfies Meta<typeof CustomRadio>;
19+
20+
export default meta;
21+
22+
type Story = StoryObj<typeof meta> & IPreviewArgs;
23+
24+
/** `items` API — the simplest case. Each option is a full card with the indicator on
25+
* the right; the whole card is the click target (it is the radio's `<label>`). Because
26+
* the card is the target, the focus ring and selected emphasis go on the card (driven by
27+
* the render props), not on the small circle — so `RadioIndicator` gets `focusRing={false}`
28+
* to avoid a double ring. This mirrors react-aria's own card-radio example. */
29+
export const Cards: Story = {
30+
args: { themeUI: "dark", backgroundUI: "light" },
31+
render: function Render() {
32+
const [value, setValue] = useState("scratch");
33+
return (
34+
<CustomRadio
35+
aria-label="Creation method"
36+
value={value}
37+
onChange={setValue}
38+
items={[
39+
{ value: "scratch", title: "Create a case from scratch" },
40+
{ value: "duplicate", title: "Duplicate an existing case" },
41+
].map(({ value: v, title }) => ({
42+
value: v,
43+
content: (rp) => (
44+
<Card
45+
hover
46+
className={cn(
47+
"flex h-fit w-[420px] items-center gap-4 p-4",
48+
rp.isSelected && "border-klerosUIComponentsPrimaryBlue",
49+
rp.isFocusVisible &&
50+
"ring-klerosUIComponentsPrimaryBlue ring-2 ring-offset-2",
51+
)}
52+
>
53+
<span className="text-klerosUIComponentsPrimaryText grow text-base">
54+
{title}
55+
</span>
56+
<RadioIndicator {...rp} focusRing={false} />
57+
</Card>
58+
),
59+
}))}
60+
/>
61+
);
62+
},
63+
};
64+
65+
/** Composition API (`children` + `<CustomRadioItem>`). Use this when options need
66+
* per-row adornments or interleaved content that the flat `items` array can't express.
67+
* Here a conditional `<TextField>` is rendered as a sibling of the selected option —
68+
* it must NOT go inside the radio's `<label>` (interactive controls there are invalid
69+
* and would toggle the radio). The `RadioIndicator` is driven by the item's render props. */
70+
export const Composition: Story = {
71+
args: { themeUI: "dark", backgroundUI: "light" },
72+
render: function Render() {
73+
const [value, setValue] = useState("all");
74+
const options = [
75+
{ value: "all", label: "All jurors in the court" },
76+
{ value: "gated", label: "Jurors owning a specific ERC-20" },
77+
];
78+
return (
79+
<CustomRadio
80+
aria-label="Eligibility"
81+
groupLabel="Eligibility"
82+
value={value}
83+
onChange={setValue}
84+
>
85+
{options.map(({ value: v, label }) => (
86+
<Fragment key={v}>
87+
<CustomRadioItem value={v}>
88+
{(rp) => (
89+
<span className="flex items-center gap-2">
90+
<RadioIndicator {...rp} small />
91+
{label}
92+
</span>
93+
)}
94+
</CustomRadioItem>
95+
{v === "gated" && value === "gated" ? (
96+
<TextField aria-label="Token address" placeholder="0x..." />
97+
) : null}
98+
</Fragment>
99+
))}
100+
</CustomRadio>
101+
);
102+
},
103+
};

0 commit comments

Comments
 (0)