Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
90 changes: 90 additions & 0 deletions static/app/components/core/toggleCard/toggleCard.stories.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
---
title: Toggle Card
description: Cards for mutually exclusive choices, independent choices, and actions.
category: forms
source: '@sentry/scraps/toggleCard'
---

import {useState} from 'react';

import {Input} from '@sentry/scraps/input';
import {Flex, Grid, Stack} from '@sentry/scraps/layout';
import {Text} from '@sentry/scraps/text';
import {ToggleCard} from '@sentry/scraps/toggleCard';

import * as Storybook from 'sentry/stories';

export const documentation = import('!!type-loader!@sentry/scraps/toggleCard');

Use `variant="radio"` for mutually exclusive choices, `variant="checkbox"` for
independent choices, and `variant="button"` for an action. Radio cards in a group
must share a `name` so arrow keys move between choices. Name the group with a
fieldset and legend or `role="radiogroup"` and `aria-label`.

`ToggleCard.Indicator` shares the card's variant and checked state. Compose the
remaining content with layout and text components. Content has `xl` padding by
default; `padding` supports denser form layouts. Disabled cards stay focusable so
a tooltip can explain why they are unavailable.

Put editable controls in `details`. They render outside the selection label while
the card is checked, so editing a field does not change the selection.

export function ToggleCardExample() {
const [value, setValue] = useState('shared');
const [checked, setChecked] = useState(false);
return (
<Stack gap="xl">
<Grid
columns="repeat(2, minmax(0, 1fr))"
gap="lg"
role="radiogroup"
aria-label="Budget mode"
>
{['shared', 'per-category'].map(option => (
<ToggleCard
variant="radio"
key={option}
name="example-budget-mode"
value={option}
checked={value === option}
onChange={event => setValue(event.currentTarget.value)}
>
<Flex align="start" gap="md">
<ToggleCard.Indicator />
<Stack gap="sm">
<Text bold>
{option === 'shared' ? 'Shared budget' : 'Per-category budget'}
</Text>
<Text variant="secondary">Choose how to manage your budget.</Text>
</Stack>
</Flex>
</ToggleCard>
))}
</Grid>
<ToggleCard
variant="checkbox"
checked={checked}
onChange={event => setChecked(event.currentTarget.checked)}
details={<Input aria-label="Threshold" type="number" defaultValue="10" />}
>
<Flex align="center" gap="md">
<ToggleCard.Indicator />
<Text>Enable a custom threshold</Text>
</Flex>
</ToggleCard>
<ToggleCard variant="button" onClick={() => {}}>
<Text>Set up manually</Text>
</ToggleCard>
<ToggleCard variant="radio" checked={false} disabled onChange={() => {}}>
<Flex align="center" gap="md">
<ToggleCard.Indicator />
<Text>Unavailable option</Text>
</Flex>
</ToggleCard>
</Stack>
);
}

<Storybook.Demo>
<ToggleCardExample />
</Storybook.Demo>
133 changes: 33 additions & 100 deletions static/app/components/forms/fields/segmentedRadioField.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import isPropValid from '@emotion/is-prop-valid';
import styled from '@emotion/styled';
import {useId} from 'react';

import {Grid} from '@sentry/scraps/layout';
import {Radio} from '@sentry/scraps/radio';
import {Grid, Stack} from '@sentry/scraps/layout';
import {Text} from '@sentry/scraps/text';
import {ToggleCard} from '@sentry/scraps/toggleCard';
import {Tooltip} from '@sentry/scraps/tooltip';

import type {RadioGroupProps} from 'sentry/components/forms/controls/radioGroup';
import type {InputFieldProps, OnEvent} from 'sentry/components/forms/fields/inputField';
import {FormField} from 'sentry/components/forms/formField';
import InteractionStateLayer from 'sentry/components/interactionStateLayer';

interface SegmentedRadioFieldProps<Choices extends string = string> extends Omit<
InputFieldProps,
Expand Down Expand Up @@ -58,123 +57,57 @@ function RadioControlGroup<C extends string>({
tooltipPosition,
...props
}: RadioGroupProps<C>) {
const groupId = useId();

return (
<Grid
{...props}
flow={{zero: 'row', xl: 'column'}}
autoColumns="minmax(0, 1fr)"
autoRows="minmax(0, 1fr)"
overflow="hidden"
radius="md"
gap="md"
role="radiogroup"
aria-label={label}
>
{choices.map(([id, name, description], index) => {
{choices.map(([id, name, description]) => {
const disabledChoice = disabledChoices.find(([choiceId]) => choiceId === id);
const disabledChoiceReason = disabledChoice?.[1];
const disabled = !!disabledChoice || groupDisabled;
const labelId = `${groupId}-${id}-label`;
const descriptionId = `${groupId}-${id}-description`;

return (
<Tooltip
key={index}
key={id}
disabled={!disabledChoiceReason}
title={disabledChoiceReason}
position={tooltipPosition}
>
<RadioItem index={index} aria-checked={value === id} disabled={disabled}>
<InteractionStateLayer />
<Radio
name={groupName}
aria-label={name?.toString()} // eslint-disable-line @typescript-eslint/no-base-to-string
disabled={disabled}
checked={value === id}
onChange={(e: React.FormEvent<HTMLInputElement>) =>
!disabled && onChange(id, e)
}
/>
<RadioLineText disabled={disabled}>{name}</RadioLineText>
{description && <Description>{description}</Description>}
</RadioItem>
<ToggleCard
variant="radio"
padding="md lg"
name={groupName ?? groupId}
value={id}
aria-labelledby={labelId}
aria-describedby={description ? descriptionId : undefined}
disabled={disabled}
checked={value === id}
onChange={e => onChange(id, e)}
>
<Stack gap="2xs">
<Text id={labelId} bold>
{name}
</Text>
{description && (
<Text id={descriptionId} variant="secondary" size="sm">
{description}
</Text>
)}
</Stack>
</ToggleCard>
</Tooltip>
);
})}
</Grid>
);
}

const shouldForwardProp = (p: PropertyKey) =>
typeof p === 'string' && !['disabled', 'animate'].includes(p) && isPropValid(p);

const RadioItem = styled('label', {shouldForwardProp})<{
index: number;
disabled?: boolean;
}>`
position: relative;
padding: ${p => p.theme.space.md} ${p => p.theme.space.lg};
display: flex;
flex-direction: column;
gap: ${p => p.theme.space['2xs']};
cursor: ${p => (p.disabled ? 'default' : 'pointer')};
outline: none;
font-weight: ${p => p.theme.font.weight.sans.regular};
border: 1px solid ${p => p.theme.tokens.border.primary};
margin: 0;

&[aria-checked='true'] {
border-color: ${p => p.theme.tokens.border.accent.vibrant} !important;
box-shadow: inset 0 0 0 1px ${p => p.theme.tokens.focus.default};
z-index: ${p => p.theme.zIndex.initial};
}

&:first-child {
border-top-left-radius: ${p => p.theme.radius.md};
border-top-right-radius: ${p => p.theme.radius.md};
}
&:last-child {
border-bottom-left-radius: ${p => p.theme.radius.md};
border-bottom-right-radius: ${p => p.theme.radius.md};
}

&:nth-child(n + 2) {
border-top-color: transparent;
}

@container (min-width: ${p => p.theme.container.xl}) {
&:nth-child(n + 2) {
border-top-color: ${p => p.theme.colors.gray200};
border-left-color: transparent;
}
&:first-child {
border-top-right-radius: 0;
border-bottom-left-radius: ${p => p.theme.radius.md};
}
&:last-child {
border-bottom-left-radius: 0;
border-top-right-radius: ${p => p.theme.radius.md};
}
}

input {
/* visually hidden */
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
`;

const RadioLineText = styled('div', {shouldForwardProp})<{disabled?: boolean}>`
opacity: ${p => (p.disabled ? 0.4 : null)};
font-size: ${p => p.theme.font.size.md};
font-weight: ${p => p.theme.font.weight.sans.medium};
color: ${p => p.theme.colors.gray800};
`;

const Description = styled('div')`
color: ${p => p.theme.tokens.content.secondary};
font-size: ${p => p.theme.font.size.sm};
line-height: 1.4em;
`;
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {Button, ButtonBar} from '@sentry/scraps/button';
import {Stack} from '@sentry/scraps/layout';

import {AgenticSetup} from './agenticSetup';
import {ManualSetupCard} from 'sentry/components/onboarding/manualSetupCard';
import * as Storybook from 'sentry/stories';

The host owns the run session, prompt, manual-setup action, analytics callbacks,
Expand Down Expand Up @@ -78,7 +77,7 @@ export function ProjectSetupExample() {
onSelectSnippet={() => {}}
feedbackSource="project-creation-agent-setup"
issueLinkReferrer="project-creation-agentic-first-issue"
manualSetup={<ManualSetupCard onSetupInBrowser={() => {}} />}
onSetupInBrowser={() => {}}
/>
</Stack>
);
Expand Down
54 changes: 49 additions & 5 deletions static/app/components/onboarding/agenticProgress/agenticSetup.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import {IconChevron} from '@sentry/icons/chevron';
import {IconSliders} from '@sentry/icons/sliders';
import {AnimatePresence, motion} from 'framer-motion';

import {Alert} from '@sentry/scraps/alert';
import {Button} from '@sentry/scraps/button';
import {Container, Stack} from '@sentry/scraps/layout';
import {Container, Flex, Grid, Stack} from '@sentry/scraps/layout';
import {Text} from '@sentry/scraps/text';
import {ToggleCard} from '@sentry/scraps/toggleCard';

import {
SETUP_CARD_ICON_SIZE,
SETUP_CARD_MARKER_PX,
} from 'sentry/components/onboarding/consts';
import {ScmCollapsibleReveal} from 'sentry/components/onboarding/scm/scmCollapsibleReveal';
import {t} from 'sentry/locale';

Expand All @@ -21,12 +28,12 @@ interface AgenticSetupProps {
hasProgressFailed: boolean;
isAgentConnected: boolean;
issueLinkReferrer: string;
/** The host supplies the action for continuing with browser setup. */
manualSetup: React.ReactNode;
onCopyCommand: (source: AgentSetupCopySource) => void;
onRefresh: () => void;
onRetry: () => void;
onSelectSnippet: (source: AgentSetupCopySource) => void;
/** The host supplies the action for continuing with browser setup. */
onSetupInBrowser: () => void;
prompt: string;
run: AgenticProgressRun | undefined;
onboardingCode?: string;
Expand All @@ -39,7 +46,7 @@ export function AgenticSetup({
onCopyCommand,
onRetry,
onSelectSnippet,
manualSetup,
onSetupInBrowser,
prompt,
feedbackSource,
issueLinkReferrer,
Expand Down Expand Up @@ -141,7 +148,44 @@ export function AgenticSetup({
{t('or')}
</Text>

{manualSetup}
<ToggleCard
variant="button"
onClick={onSetupInBrowser}
data-test-id="onboarding-setup-in-browser"
>
<Grid
columns={`${SETUP_CARD_MARKER_PX} 1fr max-content`}
gap="0 md"
width="100%"
areas={`
"icon title chevron"
". body body"
`}
>
<Flex area="icon" align="center" justify="center">
<IconSliders size={SETUP_CARD_ICON_SIZE} variant="secondary" />
</Flex>
<Flex area="title" align="center">
<Text bold size="lg">
{t('Set up manually')}
</Text>
</Flex>
<Flex area="chevron" align="center">
<IconChevron
direction="right"
size={SETUP_CARD_ICON_SIZE}
variant="secondary"
/>
</Flex>
<Container area="body" paddingTop="xs">
<Text variant="muted" size="md" density="comfortable" textWrap="pretty">
{t(
'Connect a repo, choose what to instrument and where alerts land, then follow the instructions for your project'
)}
</Text>
</Container>
</Grid>
</ToggleCard>
</Stack>
</ScmCollapsibleReveal>
</Stack>
Expand Down
Loading
Loading