Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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