diff --git a/.changeset/item-one-look.md b/.changeset/item-one-look.md new file mode 100644 index 00000000000..480f5d1cd55 --- /dev/null +++ b/.changeset/item-one-look.md @@ -0,0 +1,18 @@ +--- +'@clerk/mosaic': minor +--- + +**Breaking:** Simplify `Item` to one size and one look. Rows are always bordered and size to their content. TypeScript flags the removed props. + +| Before | After | +| -------------------------------------------- | --------------------------- | +| `` | `` | +| `` | `` (bordered) | +| ``| `` | +| `` | Removed | +| `.cl-item[data-size]`, `.cl-item-media[data-size]` | Removed | +| `.cl-item[data-variant]`, `.cl-item-group[data-variant]` | Removed | + +`Item.Label`'s `variant` is unchanged. + +Find usages with `git grep -nE "Item\.(Root|Group)[^>]*(size|variant)=|Item\.Separator|cl-item(-media|-group)?\[data-(size|variant)"`. diff --git a/.changeset/radio-cards.md b/.changeset/radio-cards.md new file mode 100644 index 00000000000..247ea707a15 --- /dev/null +++ b/.changeset/radio-cards.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +Add `Radio.Card`, a radio laid out as an `Item` row that picks its `value` when clicked anywhere. Fill it with `Radio.Media` and `Radio.Content`, which holds `Radio.Label` and an optional `Radio.Description`. `Radio.Group` takes `layout='stack' | 'grid'`, and `Radio.Section` splits a group into titled sets. diff --git a/packages/mosaic/docs/stylex.md b/packages/mosaic/docs/stylex.md index a7f9a2dd3ea..1759b006ac8 100644 --- a/packages/mosaic/docs/stylex.md +++ b/packages/mosaic/docs/stylex.md @@ -222,7 +222,7 @@ device, while touch devices look correct. ```tsx // item.tsx — the ring is an atom in the chain, not a property in the styles file - stylex.props(reset.base, focusOutline.visible, slots.item.base, slots.item[size]); + stylex.props(reset.base, focusOutline.visible, slots.item.base, xstyle); ``` It uses `:focus-visible`, never bare `:focus`, and `:has(:focus-visible)` for the diff --git a/packages/mosaic/src/components/item/item.styles.ts b/packages/mosaic/src/components/item/item.styles.ts index 2479ff3b614..c7737493363 100644 --- a/packages/mosaic/src/components/item/item.styles.ts +++ b/packages/mosaic/src/components/item/item.styles.ts @@ -10,8 +10,12 @@ export const item = stylex.create({ // `interactive` rather than gaining a hover branch here: StyleX resolves a property to the // last style that declares it, so the two can't merge. '--_cl-icon-color': colorVars['--cl-color-foreground-secondary'], + padding: space['3'], + borderColor: colorVars['--cl-color-border-subtle'], borderRadius: radiusVars['--cl-radius-lg'], - paddingInline: space['1.5'], + borderStyle: 'solid', + borderWidth: '1px', + gap: space['3'], alignItems: 'center', color: colorVars['--cl-color-foreground-secondary'], display: 'flex', @@ -22,69 +26,56 @@ export const item = stylex.create({ width: '100%', }, - // interactive rows (rendered as a link/button via `render`) gain hover + cursor. Only these - // promote on hover: a static row is not pointing at anything, so its icon and label hold. - // A row that is standing down while another action runs keeps its look rather than dimming, so - // it holds its opacity where `Button` drops to 0.5. The pointer still reaches it, which is what - // shows `not-allowed`, so every hover branch has to exclude a standing-down row itself. Both - // spellings count: a row that has to stay focusable while it waits carries `aria-disabled` - // instead of the native attribute. + // interactive rows (rendered as a link, button or radio card via `render`) gain hover + cursor. + // Only these promote on hover: a static row is not pointing at anything, so its icon and label + // hold. A row that is standing down while another action runs keeps its look rather than + // dimming, so it holds its opacity where `Button` drops to 0.5. The pointer still reaches it, + // which is what shows `not-allowed`, so every hover branch has to exclude a standing-down row + // itself. Both spellings count: a row that has to stay focusable while it waits carries + // `aria-disabled` instead of the native attribute. A radio card reflects its own state as + // `data-checked` and `data-disabled`; only a `data-disabled` row dims. interactive: { '--_cl-icon-color': { default: colorVars['--cl-color-foreground-secondary'], '@media (hover: hover)': { default: null, - ':hover:not(:disabled, [aria-disabled="true"])': colorVars['--cl-color-neutral'], + ':hover:not(:disabled, [aria-disabled="true"], [data-disabled])': colorVars['--cl-color-neutral'], + }, + }, + borderColor: { + default: colorVars['--cl-color-border-subtle'], + ':where([data-checked])': colorVars['--cl-color-foreground'], + '@media (hover: hover)': { + default: null, + ':hover:not(:disabled, [aria-disabled="true"], [data-disabled], [data-checked])': + colorVars['--cl-color-border'], }, }, backgroundColor: { default: null, - ':active:not(:disabled, [aria-disabled="true"])': colorVars['--cl-color-neutral-alpha-200'], + ':active:not(:disabled, [aria-disabled="true"], [data-disabled])': colorVars['--cl-color-neutral-alpha-200'], + ':where([data-checked])': colorVars['--cl-color-neutral-alpha-100'], '@media (hover: hover)': { - ':hover:not(:disabled, [aria-disabled="true"])': colorVars['--cl-color-neutral-alpha-100'], + ':hover:not(:disabled, [aria-disabled="true"], [data-disabled], [data-checked])': + colorVars['--cl-color-neutral-alpha-100'], }, }, color: { default: colorVars['--cl-color-foreground-secondary'], '@media (hover: hover)': { default: null, - ':hover:not(:disabled, [aria-disabled="true"])': colorVars['--cl-color-foreground'], + ':hover:not(:disabled, [aria-disabled="true"], [data-disabled])': colorVars['--cl-color-foreground'], }, }, cursor: { default: 'pointer', - ':is(:disabled, [aria-disabled="true"])': 'not-allowed', + ':is(:disabled, [aria-disabled="true"], [data-disabled])': 'not-allowed', }, - }, - - outline: { - borderColor: colorVars['--cl-color-border-subtle'], - borderStyle: 'solid', - borderWidth: '1px', - }, - - interactiveOutline: { - borderColor: { + opacity: { default: null, - '@media (hover: hover)': { - default: null, - ':hover:not(:disabled, [aria-disabled="true"])': colorVars['--cl-color-border'], - }, + ':where([data-disabled])': 0.5, }, - }, - - xs: { - gap: space['1.5'], - height: space['8'], - }, - md: { - gap: space['3'], - height: space['13'], - }, - lg: { - gap: space['3'], - paddingInline: space['3'], - height: space['16'], + position: 'relative', }, }); @@ -95,11 +86,8 @@ export const media = stylex.create({ display: 'flex', flexShrink: 0, justifyContent: 'center', + width: space['10'], }, - - xs: { width: space['5'] }, - md: { width: space['10'] }, - lg: { width: space['10'] }, }); export const content = stylex.create({ @@ -149,28 +137,9 @@ export const actions = stylex.create({ export const group = stylex.create({ base: { - width: '100%', - }, - - // The gutter belongs to the variant rather than `base`: `outline` has none, and holding it here - // keeps the two from overriding each other. - default: { - padding: space['1.5'], - }, - // Bordered rows read as separate cards, so the group drops its gutter and spaces them instead. - outline: { gap: space['2'], display: 'flex', flexDirection: 'column', - }, -}); - -export const separator = stylex.create({ - base: { - borderStyle: 'none', - backgroundColor: colorVars['--cl-color-border-subtle'], - flexShrink: 0, - height: '1px', width: '100%', }, }); diff --git a/packages/mosaic/src/components/item/item.test.tsx b/packages/mosaic/src/components/item/item.test.tsx index 25cf0034ecb..5dcf07fcbd1 100644 --- a/packages/mosaic/src/components/item/item.test.tsx +++ b/packages/mosaic/src/components/item/item.test.tsx @@ -25,33 +25,6 @@ describe('Mosaic Item', () => { expect(item).not.toHaveAttribute('data-interactive'); }); - it('reflects the size prop as a data attribute, defaulting to md', () => { - const { rerender } = render(Hi); - expect(screen.getByText('Hi')).toHaveAttribute('data-size', 'md'); - rerender(Hi); - expect(screen.getByText('Hi')).toHaveAttribute('data-size', 'xs'); - }); - - it('reads the size from the root in media', () => { - const { rerender } = render( - - - , - ); - expect(screen.getByTestId('media')).toHaveAttribute('data-size', 'xs'); - rerender( - - - , - ); - expect(screen.getByTestId('media')).toHaveAttribute('data-size', 'md'); - }); - - it('falls back to the default size when media renders outside a root', () => { - render(); - expect(screen.getByTestId('media')).toHaveAttribute('data-size', 'md'); - }); - it('reflects the label variant as a data attribute, defaulting to default', () => { const { rerender } = render(Clerk); expect(screen.getByText('Clerk')).toHaveAttribute('data-variant', 'default'); @@ -138,80 +111,16 @@ describe('Mosaic Item', () => { expect(screen.getByRole('button', { name: 'Manage' }).parentElement).toHaveClass('cl-item-actions'); }); - it('renders a group and a separator without imposing a role', () => { + it('renders a group without imposing a role', () => { render( One - Two , ); const group = screen.getByTestId('group'); expect(group).toHaveClass('cl-item-group'); expect(group).not.toHaveAttribute('role'); - expect(screen.getByTestId('sep')).toHaveClass('cl-item-separator'); - }); - - it('reflects the group variant as a data attribute, defaulting to default', () => { - const { rerender } = render(One); - expect(screen.getByTestId('group')).toHaveAttribute('data-variant', 'default'); - rerender( - - One - , - ); - expect(screen.getByTestId('group')).toHaveAttribute('data-variant', 'outline'); - }); - - it('reads the variant from the group in its rows', () => { - render( - - Outlined - , - ); - expect(screen.getByText('Outlined')).toHaveAttribute('data-variant', 'outline'); - }); - - it('falls back to the default variant when a row renders outside a group', () => { - render(Hi); - expect(screen.getByText('Hi')).toHaveAttribute('data-variant', 'default'); - }); - - it('gives an outlined row a border atom a default row does not carry', () => { - render( - <> - Plain - - Outlined - - , - ); - - const atoms = (text: string) => screen.getByText(text).className.split(' '); - expect(atoms('Outlined').filter(atom => !atoms('Plain').includes(atom))).not.toHaveLength(0); - }); - - it('takes a variant of its own, outside any group', () => { - render(Hi); - expect(screen.getByText('Hi')).toHaveAttribute('data-variant', 'outline'); - }); - - it('lets a row override the variant its group provides, in both directions', () => { - render( - <> - - Opted out - - - Opted in - - , - ); - expect(screen.getByText('Opted out')).toHaveAttribute('data-variant', 'default'); - expect(screen.getByText('Opted in')).toHaveAttribute('data-variant', 'outline'); }); it('forwards the ref to the root element', () => { diff --git a/packages/mosaic/src/components/item/item.tsx b/packages/mosaic/src/components/item/item.tsx index 012207d6052..a694c201063 100644 --- a/packages/mosaic/src/components/item/item.tsx +++ b/packages/mosaic/src/components/item/item.tsx @@ -7,110 +7,50 @@ import { mergeStyleProps, themeProps } from '../../props'; import { focusOutline } from '../../styles/focus-outline.styles'; import { reset } from '../../styles/reset.styles'; import { truncationStyles } from '../../styles/typography.styles'; +import { useOptionalFieldContext, useRegisterFieldPartId } from '../field/field.context'; import * as slots from './item.styles'; -/** The row's height and gap, and the width of the media column inside it. */ -type Size = 'xs' | 'md' | 'lg'; - -const DEFAULT_SIZE: Size = 'md'; - -/** Carries `Item.Root`'s size down to the parts it scales (`Item.Media`). */ -const ItemContext = React.createContext(DEFAULT_SIZE); - -/** How a row presents itself: seated on a shared surface, or bordered as its own card. */ -type Variant = 'default' | 'outline'; - -const DEFAULT_VARIANT: Variant = 'default'; - -/** Carries `Item.Group`'s variant down to the rows it borders (`Item.Root`). */ -const ItemGroupContext = React.createContext(DEFAULT_VARIANT); - -export type ItemProps = MosaicComponentProps<'div'> & { - /** - * Row height and gap. Also sizes a nested `Item.Media`, which reads this from - * context rather than taking its own prop, so a row scales as one unit. - * - * @default 'md' - */ - size?: Size; - /** - * `outline` borders the row so it reads as its own card. Set it here for a row - * standing on its own, or on `Item.Group` to border a whole set at once — a - * row set here wins over the group either way, so a row can opt out of an - * outlined group with `default`. - * - * @default the enclosing `Item.Group`'s variant, or `'default'` - */ - variant?: Variant; -}; +export type ItemProps = MosaicComponentProps<'div'>; /** - * Root row. Renders a `
`, or a custom element (link/button) via `render`, - * which also opts the row into hover and cursor affordances. Provides its `size` - * to the parts nested within it, and takes its `variant` from the enclosing - * `Item.Group` unless it sets one of its own. + * Root row. Renders a `
`, or a custom element (link, button, or + * `Radio.Card`) via `render`, which also opts the row into hover and cursor + * affordances. Bordered, so it reads as its own card. * * @example - * {children}}> + * {children}}> * … * Clerk * */ -const Root = React.forwardRef(function MosaicItem( - { size = DEFAULT_SIZE, variant: variantProp, render, xstyle, ...rest }, - ref, -) { - // A custom render (link/button row) opts into hover + cursor affordances. +const Root = React.forwardRef(function MosaicItem({ render, xstyle, ...rest }, ref) { + // A custom render (link, button, or radio card row) opts into hover + cursor affordances. const interactive = Boolean(render); - // The group is the default, not the authority: a row that names a variant keeps it, which is what - // lets one row opt out of an outlined group. - const groupVariant = React.useContext(ItemGroupContext); - const variant = variantProp ?? groupVariant; - const element = useRender({ + return useRender({ defaultTagName: 'div', render, ref, props: { ...mergeStyleProps( - themeProps('item', { interactive, size, variant }), - stylex.props( - reset.base, - focusOutline.visible, - slots.item.base, - slots.item[size], - variant === 'outline' && slots.item.outline, - interactive && slots.item.interactive, - interactive && variant === 'outline' && slots.item.interactiveOutline, - xstyle, - ), + themeProps('item', { interactive }), + stylex.props(reset.base, focusOutline.visible, slots.item.base, interactive && slots.item.interactive, xstyle), rest, ), }, }); - - return {element}; }); -/** - * Square column that centers its media (icon, image, avatar, or spinner). Takes - * its width from the `size` on the enclosing `Item.Root`, falling back to the - * default when rendered on its own. - */ +/** Square column that centers its media (icon, image, avatar, or spinner). */ const Media = React.forwardRef>(function MosaicItemMedia( { render, xstyle, ...rest }, ref, ) { - const size = React.useContext(ItemContext); return useRender({ defaultTagName: 'div', render, ref, props: { - ...mergeStyleProps( - themeProps('item-media', { size }), - stylex.props(reset.base, slots.media.base, slots.media[size], xstyle), - rest, - ), + ...mergeStyleProps(themeProps('item-media'), stylex.props(reset.base, slots.media.base, xstyle), rest), }, }); }); @@ -148,11 +88,15 @@ export type ItemLabelProps = MosaicComponentProps<'div'> & { variant?: LabelVariant; }; -/** The row's label. Truncates to a single line. */ +/** The row's label. Truncates to a single line. Names the radio of a `Radio.Card` row. */ const Label = React.forwardRef(function MosaicItemLabel( - { variant = DEFAULT_LABEL_VARIANT, render, xstyle, ...rest }, + { variant = DEFAULT_LABEL_VARIANT, render, xstyle, id: idProp, ...rest }, ref, ) { + const context = useOptionalFieldContext(); + const generatedId = React.useId(); + const id = idProp ?? (context ? `cl-item-${generatedId}-label` : undefined); + useRegisterFieldPartId(id, context?.setLabelIds); return useRender({ defaultTagName: 'div', render, @@ -163,15 +107,20 @@ const Label = React.forwardRef(function MosaicIt stylex.props(reset.base, slots.label.base, slots.label[variant], truncationStyles.singleLine, xstyle), rest, ), + id, }, }); }); -/** Secondary text beneath the label. Truncates to a single line. */ +/** Secondary text beneath the label. Truncates to a single line. Describes the radio of a `Radio.Card` row. */ const Description = React.forwardRef>(function MosaicItemDescription( - { render, xstyle, ...rest }, + { render, xstyle, id: idProp, ...rest }, ref, ) { + const context = useOptionalFieldContext(); + const generatedId = React.useId(); + const id = idProp ?? (context ? `cl-item-${generatedId}-description` : undefined); + useRegisterFieldPartId(id, context?.setMessageIds); return useRender({ defaultTagName: 'div', render, @@ -182,6 +131,7 @@ const Description = React.forwardRef stylex.props(reset.base, slots.description.base, truncationStyles.singleLine, xstyle), rest, ), + id, }, }); }); @@ -201,54 +151,17 @@ const Actions = React.forwardRef>(fu }); }); -export type ItemGroupProps = MosaicComponentProps<'div'> & { - /** - * `default` keeps the rows on one continuous surface, inset by the group's own - * gutter. `outline` gives each row a border and reads them as separate cards, - * so the group drops its gutter and spaces the rows apart instead. Reaches the - * rows through context, so a row only needs its own `variant` to disagree with - * the group. - * - * @default 'default' - */ - variant?: Variant; -}; - -/** - * Vertical wrapper around a set of rows. Layout only; the rows carry their own - * semantics. Provides its `variant` to the rows nested within it. - */ -const Group = React.forwardRef(function MosaicItemGroup( - { variant = DEFAULT_VARIANT, render, xstyle, ...rest }, - ref, -) { - const element = useRender({ - defaultTagName: 'div', - render, - ref, - props: { - ...mergeStyleProps( - themeProps('item-group', { variant }), - stylex.props(reset.base, slots.group.base, slots.group[variant], xstyle), - rest, - ), - }, - }); - - return {element}; -}); - -/** Thin divider (`
`) between rows or groups. */ -const Separator = React.forwardRef>(function MosaicItemSeparator( +/** Vertical stack of rows, spaced apart. Layout only; the rows carry their own semantics. */ +const Group = React.forwardRef>(function MosaicItemGroup( { render, xstyle, ...rest }, ref, ) { return useRender({ - defaultTagName: 'hr', + defaultTagName: 'div', render, ref, props: { - ...mergeStyleProps(themeProps('item-separator'), stylex.props(reset.base, slots.separator.base, xstyle), rest), + ...mergeStyleProps(themeProps('item-group'), stylex.props(reset.base, slots.group.base, xstyle), rest), }, }); }); @@ -256,12 +169,8 @@ const Separator = React.forwardRef>(fu /** * Mosaic `Item` — a row for lists of accounts, organizations, and settings. * Composed via dot syntax: `Item.Root`, `Item.Media`, `Item.Content`, - * `Item.Label`, `Item.Description`, `Item.Actions`, `Item.Group`, - * `Item.Separator`. Every part takes a `render` prop and forwards a ref. - * - * `size` is set once on `Item.Root` and reaches `Item.Media` through context, so - * a row scales as a unit rather than per part. `variant` is set on a row, or - * once on `Item.Group` to reach every row in it. + * `Item.Label`, `Item.Description`, `Item.Actions`, `Item.Group`. Every part + * takes a `render` prop and forwards a ref. */ export const Item = { Root, @@ -271,5 +180,4 @@ export const Item = { Description, Actions, Group, - Separator, }; diff --git a/packages/mosaic/src/components/radio/index.ts b/packages/mosaic/src/components/radio/index.ts index 3a4366aa830..7d7a93d49cc 100644 --- a/packages/mosaic/src/components/radio/index.ts +++ b/packages/mosaic/src/components/radio/index.ts @@ -1,2 +1,2 @@ export { Radio } from './radio'; -export type { RadioGroupProps, RadioItemProps } from './radio'; +export type { RadioCardProps, RadioGroupProps, RadioItemProps, RadioLayout, RadioSectionProps } from './radio'; diff --git a/packages/mosaic/src/components/radio/radio.styles.ts b/packages/mosaic/src/components/radio/radio.styles.ts index be75ad779cb..48ee779891d 100644 --- a/packages/mosaic/src/components/radio/radio.styles.ts +++ b/packages/mosaic/src/components/radio/radio.styles.ts @@ -6,10 +6,16 @@ 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', + section: { + margin: 0, + padding: 0, + borderStyle: 'none', + minInlineSize: 0, + width: '100%', + }, + sectionLabel: { + padding: 0, + marginBottom: space['3'], }, root: { display: 'inline-flex', @@ -89,3 +95,21 @@ export const indicatorSizes = stylex.create({ sm: { height: space['1.5'], width: space['1.5'] }, md: { height: space['1.5'], width: space['1.5'] }, }); + +export const layouts = stylex.create({ + stack: { + gap: { + default: space['3'], + ':has(> fieldset)': space['5'], + }, + display: 'flex', + flexDirection: 'column', + width: '100%', + }, + grid: { + gap: space['3'], + display: 'grid', + gridTemplateColumns: `repeat(auto-fill, minmax(min(14rem, 100%), 1fr))`, + width: '100%', + }, +}); diff --git a/packages/mosaic/src/components/radio/radio.test.tsx b/packages/mosaic/src/components/radio/radio.test.tsx index c64c39d0b14..9d9c6f8a29a 100644 --- a/packages/mosaic/src/components/radio/radio.test.tsx +++ b/packages/mosaic/src/components/radio/radio.test.tsx @@ -1,5 +1,5 @@ import * as stylex from '@stylexjs/stylex'; -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { describe, expect, it, vi } from 'vitest'; @@ -224,9 +224,155 @@ describe('Mosaic Radio', () => { ); expect(screen.getByRole('radio', { name: 'All organizations' })).toHaveAttribute('value', 'all'); }); + + it('reflects its layout for theming', () => { + render(); + expect(screen.getByRole('radiogroup')).toHaveAttribute('data-layout', 'grid'); + }); + }); + + describe('Card', () => { + function Card({ value, label, description, disabled }: CardProps) { + return ( + + + {label} + {description ? {description} : null} + + + ); + } + + function Picker(props: Partial> & { oktaDisabled?: boolean }) { + const { oktaDisabled, ...groupProps } = props; + return ( + + + + + ); + } + + it('names the radio from its label and describes it with its description', () => { + render(); + const radio = screen.getByRole('radio', { name: 'Okta workforce' }); + expect(radio).toHaveAccessibleDescription('Sign in with Okta.'); + }); + + it('picks its value when the user clicks anywhere on the card', async () => { + const onValueChange = vi.fn(); + render(); + + await userEvent.click(screen.getByText('Sign in with Okta.')); + + expect(onValueChange).toHaveBeenCalledWith('okta'); + expect(screen.getByRole('radio', { name: 'Okta workforce' })).toBeChecked(); + }); + + it('reflects whether it is checked for theming', async () => { + render(); + const okta = screen.getByText('Okta workforce').closest('.cl-radio-card'); + const google = screen.getByText('Google Workspace').closest('.cl-radio-card'); + expect(okta).not.toHaveAttribute('data-checked'); + expect(google).toHaveAttribute('data-checked'); + + await userEvent.click(screen.getByText('Okta workforce')); + + expect(okta).toHaveAttribute('data-checked'); + expect(google).not.toHaveAttribute('data-checked'); + }); + + it('cannot be picked when it is disabled', async () => { + const onValueChange = vi.fn(); + render( + , + ); + const radio = screen.getByRole('radio', { name: 'Okta workforce' }); + expect(radio).toBeDisabled(); + expect(screen.getByText('Okta workforce').closest('.cl-radio-card')).toHaveAttribute('data-disabled'); + + await userEvent.click(screen.getByText('Okta workforce')); + + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('cannot be picked when its group is disabled', () => { + render(); + for (const radio of screen.getAllByRole('radio')) { + expect(radio).toBeDisabled(); + } + }); + }); + + describe('Section', () => { + it('groups its radios under its label and shares the group name across sections', () => { + render( + + + + + + + + , + ); + const saml = screen.getByRole('group', { name: 'SAML' }); + const oidc = screen.getByRole('group', { name: 'OpenID Connect (OIDC)' }); + const okta = within(saml).getByRole('radio', { name: 'Okta workforce' }); + const oidcRadio = within(oidc).getByRole('radio', { name: 'OIDC Provider' }); + expect(okta.getAttribute('name')).toBeTruthy(); + expect(oidcRadio).toHaveAttribute('name', okta.getAttribute('name') ?? ''); + }); + + it('reflects its layout for theming', () => { + render( + + + + + , + ); + expect(screen.getByRole('group', { name: 'SAML' })).toHaveAttribute('data-layout', 'grid'); + }); }); }); +interface CardProps { + value: string; + label: string; + description?: string; + disabled?: boolean; +} + function formEntries(container: HTMLElement) { const form = container.querySelector('form'); if (!form) { diff --git a/packages/mosaic/src/components/radio/radio.tsx b/packages/mosaic/src/components/radio/radio.tsx index 4c045101d1c..07ae17bedba 100644 --- a/packages/mosaic/src/components/radio/radio.tsx +++ b/packages/mosaic/src/components/radio/radio.tsx @@ -6,9 +6,15 @@ 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 { visuallyHidden } from '../../styles/visually-hidden.styles'; +import { FieldProvider, useOptionalFieldContext, useOptionalFieldControlProps } from '../field/field.context'; +import { Item } from '../item'; +import { Text } from '../text'; import { radioInputMarker } from './radio.markers.stylex'; -import { firstLine, indicatorSizes, sizes, styles } from './radio.styles'; +import { firstLine, indicatorSizes, layouts, sizes, styles } from './radio.styles'; + +/** `stack` gives each choice its own row. `grid` fits as many columns of cards as the width allows. */ +export type RadioLayout = 'stack' | 'grid'; export interface RadioGroupProps extends Omit, 'defaultValue' | 'className' | 'style'>, MosaicStyleProps { @@ -17,6 +23,8 @@ export interface RadioGroupProps onValueChange?: (value: string) => void; name?: string; disabled?: boolean; + /** @default 'stack' */ + layout?: RadioLayout; } interface RadioGroupContextValue { @@ -30,7 +38,7 @@ interface RadioGroupContextValue { const RadioGroupContext = React.createContext(null); const RadioGroup = React.forwardRef(function MosaicRadioGroup( - { value: valueProp, defaultValue, onValueChange, name: nameProp, disabled, xstyle, ...rest }, + { value: valueProp, defaultValue, onValueChange, name: nameProp, disabled, layout = 'stack', xstyle, ...rest }, ref, ) { const generatedName = React.useId(); @@ -50,7 +58,11 @@ const RadioGroup = React.forwardRef(function Mo ref={ref} role='radiogroup' aria-disabled={disabled || undefined} - {...mergeStyleProps(themeProps('radio-group'), stylex.props(reset.base, styles.group, xstyle), rest)} + {...mergeStyleProps( + themeProps('radio-group', { layout }), + stylex.props(reset.base, layouts[layout], xstyle), + rest, + )} /> ); @@ -143,7 +155,106 @@ const RadioItem = React.forwardRef(function Mo ); }); +export interface RadioSectionProps + extends Omit, 'className' | 'style'>, MosaicStyleProps { + /** The section's heading, rendered as its ``. */ + label: React.ReactNode; + /** @default 'stack' */ + layout?: RadioLayout; +} + +/** + * A titled set of choices inside a `Radio.Group`. Renders a `
`, so assistive tech + * announces its label as a group. Its radios still belong to the group around it: they share its + * value and name, and arrow keys move across sections. + */ +const RadioSection = React.forwardRef(function MosaicRadioSection( + { label, layout = 'stack', xstyle, children, ...rest }, + ref, +) { + return ( +
+ } + color='foreground-secondary' + xstyle={styles.sectionLabel} + > + {label} + +
+ {children} +
+
+ ); +}); + +export interface RadioCardProps + extends Omit, 'className' | 'style'>, MosaicStyleProps { + value: string; + disabled?: boolean; +} + +/** + * A choice of the `Radio.Group` around it, laid out as an `Item` row. It renders a `
); } @@ -311,14 +298,10 @@ export function ThemedScrollbar() { '--cl-scrollbar-thumb-active': 'oklch(0.55 0.25 295)', }} > - - {manyRows.map(name => ( - - ))} - +
); @@ -344,21 +327,21 @@ export function ShadowIndicators() { return ( <>