From 8b9525065f14d78fae44ebb9826c9a54544b154c Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 15 Oct 2026 09:53:25 -0400 Subject: [PATCH] feat(mosaic): add Radio component --- .changeset/mosaic-radio.md | 2 + packages/mosaic/src/components/radio/index.ts | 2 + .../components/radio/radio.markers.stylex.ts | 3 + .../src/components/radio/radio.styles.ts | 87 ++++++++ .../src/components/radio/radio.test.tsx | 207 ++++++++++++++++++ .../mosaic/src/components/radio/radio.tsx | 147 +++++++++++++ packages/mosaic/src/styles/index.ts | 2 + .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 15 ++ packages/swingset/src/stories/radio.mdx | 68 ++++++ .../swingset/src/stories/radio.stories.tsx | 93 ++++++++ 11 files changed, 627 insertions(+) create mode 100644 .changeset/mosaic-radio.md create mode 100644 packages/mosaic/src/components/radio/index.ts create mode 100644 packages/mosaic/src/components/radio/radio.markers.stylex.ts create mode 100644 packages/mosaic/src/components/radio/radio.styles.ts create mode 100644 packages/mosaic/src/components/radio/radio.test.tsx create mode 100644 packages/mosaic/src/components/radio/radio.tsx create mode 100644 packages/swingset/src/stories/radio.mdx create mode 100644 packages/swingset/src/stories/radio.stories.tsx diff --git a/.changeset/mosaic-radio.md b/.changeset/mosaic-radio.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-radio.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/radio/index.ts b/packages/mosaic/src/components/radio/index.ts new file mode 100644 index 00000000000..3a4366aa830 --- /dev/null +++ b/packages/mosaic/src/components/radio/index.ts @@ -0,0 +1,2 @@ +export { Radio } from './radio'; +export type { RadioGroupProps, RadioItemProps } from './radio'; diff --git a/packages/mosaic/src/components/radio/radio.markers.stylex.ts b/packages/mosaic/src/components/radio/radio.markers.stylex.ts new file mode 100644 index 00000000000..aa10bfbee15 --- /dev/null +++ b/packages/mosaic/src/components/radio/radio.markers.stylex.ts @@ -0,0 +1,3 @@ +import * as stylex from '@stylexjs/stylex'; + +export const radioInputMarker = stylex.defineMarker(); diff --git a/packages/mosaic/src/components/radio/radio.styles.ts b/packages/mosaic/src/components/radio/radio.styles.ts new file mode 100644 index 00000000000..5d04a7ea2ce --- /dev/null +++ b/packages/mosaic/src/components/radio/radio.styles.ts @@ -0,0 +1,87 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, durationVars, radiusVars, space } from '../../tokens.stylex'; +import { radioInputMarker } from './radio.markers.stylex'; + +const hoverBorderColor = 'light-dark(#bebebe, #525252)'; + +export const styles = stylex.create({ + group: { + gap: space['3'], + display: 'flex', + flexDirection: 'column', + }, + root: { + display: 'inline-flex', + flexShrink: 0, + position: 'relative', + verticalAlign: 'middle', + }, + input: { + borderColor: { + default: colorVars['--cl-color-border'], + ':checked': colorVars['--cl-color-brand'], + '@media (hover: hover)': { + ':hover:not(:disabled):not(:checked)': hoverBorderColor, + }, + }, + borderRadius: radiusVars['--cl-radius-full'], + borderStyle: 'solid', + borderWidth: '1px', + appearance: 'none', + backgroundColor: { + default: colorVars['--cl-color-background'], + ':checked': colorVars['--cl-color-brand'], + }, + cursor: { default: 'pointer', ':disabled': 'not-allowed' }, + display: 'block', + flexShrink: 0, + opacity: { default: null, ':disabled': 0.5 }, + position: 'relative', + transitionDuration: durationVars['--cl-duration-fast'], + transitionProperty: 'background-color, border-color', + transitionTimingFunction: 'linear', + }, + hitTarget: { + '::before': { + inset: `calc(-1 * ${space['3']})`, + content: '""', + display: { default: 'none', '@media (pointer: coarse)': 'block' }, + position: 'absolute', + }, + }, + fieldHitTarget: { + '::before': { + inset: `calc(-1 * (${space['2']} + 1px))`, + display: 'block', + }, + }, + indicator: { + borderRadius: radiusVars['--cl-radius-full'], + backgroundColor: colorVars['--cl-color-brand-foreground'], + display: { + default: 'none', + [stylex.when.siblingBefore(':checked', radioInputMarker)]: 'block', + }, + insetBlockStart: '50%', + insetInlineStart: '50%', + pointerEvents: 'none', + position: 'absolute', + transform: 'translate(-50%, -50%)', + }, +}); + +export const sizes = stylex.create({ + sm: { height: space['3.5'], width: space['3.5'] }, + md: { height: space['4'], width: space['4'] }, +}); + +export const firstLine = stylex.create({ + sm: { marginTop: `max(0px, calc((1lh - ${space['3.5']}) / 2))` }, + md: { marginTop: `max(0px, calc((1lh - ${space['4']}) / 2))` }, +}); + +export const indicatorSizes = stylex.create({ + sm: { height: space['1.5'], width: space['1.5'] }, + md: { height: space['1.5'], width: space['1.5'] }, +}); diff --git a/packages/mosaic/src/components/radio/radio.test.tsx b/packages/mosaic/src/components/radio/radio.test.tsx new file mode 100644 index 00000000000..768ccba55f4 --- /dev/null +++ b/packages/mosaic/src/components/radio/radio.test.tsx @@ -0,0 +1,207 @@ +import * as stylex from '@stylexjs/stylex'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { Field } from '../field'; +import { Radio } from './radio'; + +const atoms = stylex.create({ + spaced: { marginTop: '8px' }, +}); + +describe('Mosaic Radio', () => { + it('renders a native radio wrapped in the slot element', () => { + render(); + const input = screen.getByRole('radio', { name: 'All organizations' }); + expect(input).toHaveClass('cl-radio-input'); + expect(input.parentElement).toHaveClass('cl-radio'); + }); + + it('checks one radio at a time within a name', async () => { + const onChange = vi.fn(); + render( + <> + + + , + ); + + await userEvent.click(screen.getByRole('radio', { name: 'Selected' })); + + expect(screen.getByRole('radio', { name: 'Selected' })).toBeChecked(); + expect(screen.getByRole('radio', { name: 'All' })).not.toBeChecked(); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('reflects the size axis on the slot element', () => { + render( + , + ); + expect(screen.getByRole('radio').parentElement).toHaveAttribute('data-size', 'sm'); + }); + + it('disables the input', () => { + render( + , + ); + expect(screen.getByRole('radio')).toBeDisabled(); + }); + + it('merges xstyle atoms onto the slot element', () => { + render( + , + ); + expect(screen.getByRole('radio').parentElement).toHaveClass('cl-radio', stylex.props(atoms.spaced).className ?? ''); + }); + + it('forwards the ref and arbitrary input props to the input', () => { + const ref = React.createRef(); + render( + , + ); + const input = screen.getByRole('radio'); + expect(ref.current).toBe(input); + expect(input).toHaveAttribute('name', 'access'); + expect(input).toHaveAttribute('value', 'all'); + }); + + it('takes its label, description and disabled state from a Field', async () => { + render( + + + + All organizations + Applies to every organization you belong to. + + , + ); + + const radio = screen.getByRole('radio', { name: 'All organizations' }); + expect(radio).toHaveAttribute( + 'aria-describedby', + screen.getByText('Applies to every organization you belong to.').id, + ); + await userEvent.click(screen.getByText('All organizations')); + expect(radio).toBeChecked(); + }); + + it('disables the radio inside a disabled Field', () => { + render( + + + All organizations + , + ); + expect(screen.getByRole('radio', { name: 'All organizations' })).toBeDisabled(); + }); + + describe('Group', () => { + function Group(props: Partial>) { + return ( + + + + + ); + } + + it('renders a labelled radiogroup', () => { + render(); + expect(screen.getByRole('radiogroup', { name: 'Organization access' })).toHaveClass('cl-radio-group'); + }); + + it('checks the item matching its value and reports the one the user picks', async () => { + const onValueChange = vi.fn(); + render( + , + ); + expect(screen.getByRole('radio', { name: 'All' })).toBeChecked(); + + await userEvent.click(screen.getByRole('radio', { name: 'Selected' })); + + expect(onValueChange).toHaveBeenCalledWith('selected'); + expect(screen.getByRole('radio', { name: 'All' })).toBeChecked(); + }); + + it('tracks the selection itself from a default value', async () => { + render(); + + await userEvent.click(screen.getByRole('radio', { name: 'Selected' })); + + expect(screen.getByRole('radio', { name: 'Selected' })).toBeChecked(); + expect(screen.getByRole('radio', { name: 'All' })).not.toBeChecked(); + }); + + it('gives every item the same name', () => { + render(); + const [all, selected] = screen.getAllByRole('radio'); + expect(all.getAttribute('name')).toBeTruthy(); + expect(selected).toHaveAttribute('name', all.getAttribute('name') ?? ''); + }); + + it('posts the name it is given', () => { + render(); + expect(screen.getByRole('radio', { name: 'All' })).toHaveAttribute('name', 'access'); + }); + + it('disables every item', () => { + render(); + for (const radio of screen.getAllByRole('radio')) { + expect(radio).toBeDisabled(); + } + }); + + it('labels each item with its own Field', () => { + render( + + + + All organizations + + , + ); + expect(screen.getByRole('radio', { name: 'All organizations' })).toHaveAttribute('value', 'all'); + }); + }); +}); diff --git a/packages/mosaic/src/components/radio/radio.tsx b/packages/mosaic/src/components/radio/radio.tsx new file mode 100644 index 00000000000..cc6af15d4e6 --- /dev/null +++ b/packages/mosaic/src/components/radio/radio.tsx @@ -0,0 +1,147 @@ +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import { useControllableState } from '../../primitives/hooks'; +import type { MosaicStyleProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { focusOutline } from '../../styles/focus-outline.styles'; +import { reset } from '../../styles/reset.styles'; +import { useOptionalFieldContext, useOptionalFieldControlProps } from '../field/field.context'; +import { radioInputMarker } from './radio.markers.stylex'; +import { firstLine, indicatorSizes, sizes, styles } from './radio.styles'; + +export interface RadioGroupProps + extends Omit, 'defaultValue' | 'className' | 'style'>, MosaicStyleProps { + value?: string; + defaultValue?: string; + onValueChange?: (value: string) => void; + name?: string; + disabled?: boolean; +} + +interface RadioGroupContextValue { + name: string; + value: string | undefined; + setValue: (value: string) => void; + disabled: boolean | undefined; +} + +const RadioGroupContext = React.createContext(null); + +const RadioGroup = React.forwardRef(function MosaicRadioGroup( + { value: valueProp, defaultValue, onValueChange, name: nameProp, disabled, xstyle, ...rest }, + ref, +) { + const generatedName = React.useId(); + const [value, setValue] = useControllableState(valueProp, defaultValue, next => { + if (next !== undefined) { + onValueChange?.(next); + } + }); + const context = React.useMemo( + () => ({ name: nameProp ?? generatedName, value, setValue, disabled }), + [nameProp, generatedName, value, setValue, disabled], + ); + + return ( + +
+ + ); +}); + +export interface RadioItemProps + extends + Omit, 'type' | 'size' | 'value' | 'className' | 'style'>, + MosaicStyleProps { + size?: 'sm' | 'md'; + value?: string; +} + +const RadioItem = React.forwardRef(function MosaicRadioItem( + { + size = 'md', + value, + checked: checkedProp, + onChange, + name: nameProp, + disabled: disabledProp, + required: requiredProp, + id, + 'aria-labelledby': ariaLabelledBy, + 'aria-describedby': ariaDescribedBy, + xstyle, + ...rest + }, + ref, +) { + const horizontalField = useOptionalFieldContext()?.orientation === 'horizontal'; + const fieldProps = useOptionalFieldControlProps({ + id, + disabled: disabledProp, + required: requiredProp, + ariaLabelledBy, + ariaDescribedBy, + }); + const group = React.useContext(RadioGroupContext); + const disabled = (fieldProps?.disabled ?? disabledProp) || group?.disabled; + const checked = group && value !== undefined ? group.value === value : checkedProp; + + return ( + + { + onChange?.(event); + if (group && value !== undefined) { + group.setValue(value); + } + }} + disabled={disabled} + required={fieldProps?.required ?? requiredProp} + id={fieldProps?.id ?? id} + aria-labelledby={fieldProps?.['aria-labelledby'] ?? ariaLabelledBy} + aria-describedby={fieldProps?.['aria-describedby'] ?? ariaDescribedBy} + {...mergeStyleProps( + themeProps('radio-input'), + stylex.props( + reset.base, + focusOutline.visible, + styles.input, + styles.hitTarget, + horizontalField && styles.fieldHitTarget, + sizes[size], + radioInputMarker, + ), + rest, + )} + /> + + + ); +}); + +export const Radio = { + Group: RadioGroup, + Item: RadioItem, +}; diff --git a/packages/mosaic/src/styles/index.ts b/packages/mosaic/src/styles/index.ts index eb987e3917e..99619198819 100644 --- a/packages/mosaic/src/styles/index.ts +++ b/packages/mosaic/src/styles/index.ts @@ -91,6 +91,8 @@ export type { } from '../components/menu'; export { scrollAreaRoot, scrollAreaVars, scrollAreaViewport } from '../components/scroll-area'; export type { ScrollAreaAxis, ScrollAreaGutter } from '../components/scroll-area'; +export { Radio } from '../components/radio'; +export type { RadioGroupProps, RadioItemProps } from '../components/radio'; export { Select } from '../components/select'; export type { SelectItem, diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 67506cfd3ce..3ad5ae0e5a6 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -93,6 +93,7 @@ const docModules: Record> = { panel: dynamic(() => import('../stories/panel.component.mdx')), popover: dynamic(() => import('../stories/popover.component.mdx')), profile: dynamic(() => import('../stories/profile.component.mdx')), + radio: dynamic(() => import('../stories/radio.mdx')), section: dynamic(() => import('../stories/section.mdx')), 'segmented-control': dynamic(() => import('../stories/segmented-control.mdx')), select: dynamic(() => import('../stories/select.component.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index ce1fbadf49f..28d448fa5e2 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -225,6 +225,13 @@ import { meta as profileComponentMeta, Transitions as ProfileTransitions, } from '../stories/profile.component.stories'; +import { + Default as RadioDefault, + Descriptions as RadioDescriptions, + meta as radioMeta, + Sizes as RadioSizes, + States as RadioStates, +} from '../stories/radio.stories'; import { AuthenticatorOTP as ReverificationAuthenticatorOTP, BackupCode as ReverificationBackupCode, @@ -620,6 +627,13 @@ const profileComponentModule: StoryModule = { Customized: ProfileCustomized, Transitions: ProfileTransitions, }; +const radioModule: StoryModule = { + meta: radioMeta, + Default: RadioDefault, + Descriptions: RadioDescriptions, + States: RadioStates, + Sizes: RadioSizes, +}; const dataListModule: StoryModule = { meta: dataListMeta, @@ -1056,6 +1070,7 @@ export const registry: StoryModule[] = [ panelModule, popoverComponentModule, profileComponentModule, + radioModule, sectionModule, segmentedControlModule, selectComponentModule, diff --git a/packages/swingset/src/stories/radio.mdx b/packages/swingset/src/stories/radio.mdx new file mode 100644 index 00000000000..93ca08d6aa7 --- /dev/null +++ b/packages/swingset/src/stories/radio.mdx @@ -0,0 +1,68 @@ +import * as RadioStories from './radio.stories'; + +# Radio + +`Radio.Group` holds one choice out of several, and each `Radio.Item` is a styled native ``. The group gives its items a shared `name` and checks the item whose `value` matches its own, controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`). Put each item in a [Field](/components/field) with `orientation='horizontal'` to give it a label and a description. + +## Example + + + +## Usage + +```tsx +import { Field } from '@clerk/mosaic/components/field'; +import { Radio } from '@clerk/mosaic/components/radio'; + + + + + Monthly + + + + Yearly + +; +``` + +## Parts + +| Part | Slot | Description | +| ------------- | ----------------------- | ---------------------------------------------------------------------------------------- | +| `Radio.Group` | `radio-group` | The `radiogroup`. Owns `value`, `defaultValue`, `onValueChange`, `name` and `disabled`. | +| `Radio.Item` | `radio` / `radio-input` | One choice. `data-size`, `data-disabled`. Forwards its ref and input props to the input. | +| ↳ indicator | `radio-indicator` | The dot, shown while the item is checked. | + +--- + +## Examples + +### Descriptions + + + +### States + + + +### Sizes + + diff --git a/packages/swingset/src/stories/radio.stories.tsx b/packages/swingset/src/stories/radio.stories.tsx new file mode 100644 index 00000000000..738b197a41b --- /dev/null +++ b/packages/swingset/src/stories/radio.stories.tsx @@ -0,0 +1,93 @@ +import { Field } from '@clerk/mosaic/components/field'; +import { Radio } from '@clerk/mosaic/components/radio'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './radio.stories?raw'; + +export const meta: StoryMeta = { + group: 'Components', + status: 'wip', + title: 'Radio', + source: 'packages/mosaic/src/components/radio/radio.tsx', +}; + +export function Default() { + return ( + + + + Monthly + + + + Yearly + + + ); +} + +export function Descriptions() { + return ( + + + + + All repositories + Applies to every current and future repository. + + + + + + Only select repositories + Select at least one repository. + + + + ); +} + +export function States() { + return ( +
+ + + + +
+ ); +} + +export function Sizes() { + return ( +
+ + +
+ ); +}