Skip to content

feat: add RadioCard slot-based styling and recipe story #1726

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
wants to merge 7 commits into
base: main
Choose a base branch
from
5 changes: 5 additions & 0 deletions .changeset/common-goats-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@launchpad-ui/components": minor
---

Add variant prop support to RadioGroup for card styling - replaces slot-based approach with variant="card" prop
3 changes: 2 additions & 1 deletion packages/components/src/Radio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ interface RadioProps extends AriaRadioProps {
const RadioContext = createContext<ContextValue<RadioProps, HTMLLabelElement>>(null);

const RadioIcon = ({ isSelected }: Partial<RadioRenderProps>) => (
<div className={radioIconStyles()}>
<div data-radio-icon className={radioIconStyles()}>
{isSelected ? (
<svg aria-hidden="true" className={styles.icon} viewBox="0 0 16 16">
<path
Expand All @@ -42,6 +42,7 @@ const RadioIcon = ({ isSelected }: Partial<RadioRenderProps>) => (
*/
const Radio = ({ ref, ...props }: RadioProps) => {
[props, ref] = useLPContextProps(props, ref, RadioContext);

return (
<AriaRadio
{...props}
Expand Down
22 changes: 18 additions & 4 deletions packages/components/src/RadioGroup.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { VariantProps } from 'class-variance-authority';
import type { Ref } from 'react';
import type { RadioGroupProps as AriaRadioGroupProps, ContextValue } from 'react-aria-components';

Expand All @@ -8,9 +9,19 @@ import { RadioGroup as AriaRadioGroup, composeRenderProps } from 'react-aria-com
import styles from './styles/RadioGroup.module.css';
import { useLPContextProps } from './utils';

const radioGroupStyles = cva(styles.group);
const radioGroupStyles = cva(styles.group, {
variants: {
variant: {
default: '',
card: styles.card,
},
},
defaultVariants: {
variant: 'default',
},
});

interface RadioGroupProps extends AriaRadioGroupProps {
interface RadioGroupProps extends AriaRadioGroupProps, VariantProps<typeof radioGroupStyles> {
ref?: Ref<HTMLDivElement>;
}

Expand All @@ -23,12 +34,15 @@ const RadioGroupContext = createContext<ContextValue<RadioGroupProps, HTMLDivEle
*/
const RadioGroup = ({ ref, ...props }: RadioGroupProps) => {
[props, ref] = useLPContextProps(props, ref, RadioGroupContext);
const { variant = 'default', ...restProps } = props;

return (
<AriaRadioGroup
{...props}
{...restProps}
ref={ref}
data-variant={variant}
className={composeRenderProps(props.className, (className, renderProps) =>
radioGroupStyles({ ...renderProps, className }),
radioGroupStyles({ ...renderProps, variant, className }),
)}
/>
);
Expand Down
119 changes: 119 additions & 0 deletions packages/components/src/styles/RadioGroup.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,122 @@
gap: var(--lp-spacing-300);
}
}

.card {
align-items: stretch;
gap: var(--lp-spacing-300);
}

/* Card variant styling - targets child Radio components when parent has data-variant="card" */
.group[data-variant='card'] label[data-rac] {
border: var(--lp-border-width-200) solid var(--lp-color-border-ui-primary);
border-radius: var(--lp-border-radius-medium);
background: var(--lp-color-bg-ui-primary);
transition: all var(--lp-duration-100) ease-in-out;
flex-direction: column;
align-items: flex-start;
gap: 0;

&[data-hovered] {
border-color: var(--lp-color-border-interactive-primary-hover);
background: var(--lp-color-bg-interactive-secondary-hover);
}

&[data-pressed] {
border-color: var(--lp-color-border-interactive-primary-active);
background: var(--lp-color-bg-interactive-secondary-active);
}

&[data-focus-visible] {
outline: 1px solid var(--lp-color-shadow-interactive-focus);
outline-offset: 1px;
border-color: var(--lp-color-border-interactive-primary-base);
}

&[data-selected] {
border-color: var(--lp-color-text-interactive-base);
background: var(--lp-color-bg-interactive-primary-subtle);

& [slot='description'] {
display: block;
}

& [slot='heading'] {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}

&[data-disabled] {
border-color: var(--lp-color-border-ui-secondary);
background: var(--lp-color-bg-ui-secondary);
color: var(--lp-color-text-interactive-disabled);
cursor: not-allowed;
}

&:has([slot='heading']) {
display: grid;
grid-template-areas:
'heading radio'
'description description';
grid-template-columns: 1fr auto;
align-items: flex-start;
position: relative;

& [data-radio-icon] {
transition: all var(--lp-duration-100) ease-in-out;
grid-area: radio;
position: absolute;
right: var(--lp-spacing-500);
align-self: center;
}
}

&:has([slot='heading']):not(:has([slot='description'])) {
grid-template-areas: 'heading';
}

& [slot='heading'] {
grid-area: heading;
display: flex;
align-items: center;
gap: var(--lp-spacing-500);
padding: var(--lp-spacing-400) var(--lp-spacing-500);
}

& [slot='heading'] [slot='icon'] {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}

& [slot='heading'] [slot='label'] {
font: var(--lp-text-label-1-semibold);
}

& [slot='heading'] [slot='subtitle'] {
font: var(--lp-text-body-2-regular);
color: var(--lp-color-text-ui-secondary);
text-align: left;
white-space: normal;
}

& [slot='description'] {
grid-area: description;
font: var(--lp-text-body-2-regular);
color: var(--lp-color-text-ui-secondary);
margin-top: var(--lp-spacing-200);
background: var(--lp-color-bg-interactive-selected);
padding: var(--lp-spacing-500);
display: none;
border-bottom-left-radius: var(--lp-border-radius-medium);
border-bottom-right-radius: var(--lp-border-radius-medium);
transition: all var(--lp-duration-100) ease-in-out;
}

&[data-selected] [slot='heading'] [slot='subtitle'] {
color: var(--lp-color-text-ui-primary);
font-weight: var(--lp-font-weight-medium);
}
}
89 changes: 89 additions & 0 deletions packages/components/stories/RadioGroup.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import type { ComponentType } from 'react';

import { Icon } from '@launchpad-ui/icons';
import { vars } from '@launchpad-ui/vars';
import { userEvent, within } from 'storybook/test';

import { Button } from '../src/Button';
Expand Down Expand Up @@ -80,3 +82,90 @@ export const Validation: Story = {
await userEvent.click(canvas.getByRole('button'));
},
};

export const Card: Story = {
args: {
variant: 'card',
defaultValue: 'feature',
},
render: (args) => {
return (
<div
style={{
width: vars.size['320'],
}}
>
<RadioGroup {...args}>
<Label>Experiment type</Label>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: vars.spacing[300],
}}
>
<Radio value="feature">
<div slot="heading">
<div slot="icon">
<Icon name="flag" size="medium" />
</div>
<div>
<div slot="label">Feature change</div>
<div slot="subtitle">A/B test different variations</div>
</div>
</div>
<div slot="description">Compare treatments to see which one wins</div>
</Radio>
<Radio value="funnel">
<div slot="heading">
<div slot="icon">
<Icon name="flask" size="medium" />
</div>
<div>
<div slot="label">Funnel optimization</div>
<div slot="subtitle">Multi-step conversion tracking</div>
</div>
</div>
<div slot="description">Track the success of a multi-step user flow</div>
</Radio>
<Radio value="export">
<div slot="heading">
<div slot="icon">
<Icon name="data" size="medium" />
</div>
<div>
<div slot="label">Data Export only</div>
<div slot="subtitle">Raw data for analysis</div>
</div>
</div>
<div slot="description">Create custom experiment analysis in your warehouse</div>
</Radio>
<Radio value="snowflake" isDisabled>
<div slot="heading">
<div slot="icon">
<Icon name="circle" size="medium" />
</div>
<div>
<div slot="label">Snowflake native</div>
<div slot="subtitle">Warehouse-powered insights</div>
</div>
</div>
<div slot="description">Analysis powered by your Snowflake warehouse</div>
</Radio>
<Radio value="simple">
<div slot="heading">
<div slot="icon">
<Icon name="gear" size="medium" />
</div>
<div>
<div slot="label">Simple option</div>
<div slot="subtitle">Basic configuration</div>
</div>
</div>
</Radio>
</div>
</RadioGroup>
</div>
);
},
};
Loading