-
Notifications
You must be signed in to change notification settings - Fork 3
feat: custom-radio #88
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
kemuru
wants to merge
2
commits into
main
Choose a base branch
from
feat/custom-radio
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", | ||
| 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, | ||
| )} | ||
| /> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| }, | ||
| }; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.