Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/sso-provider-card-radio-indicator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/ui": patch
---

Add a visible radio indicator to each provider card on the `<ConfigureSSO />` Select Provider step. Extends the internal `SimpleButton` primitive with a polymorphic `as` prop (`'button'` by default, `'label'` opt-in) so the card wrapper reuses the shared outline-button styling instead of duplicating it inline.
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,18 @@ import { useUser } from '@clerk/shared/react/index';
import React from 'react';

import type { LocalizationKey } from '@/customizables';
import { Box, Col, descriptors, Flow, Grid, localizationKeys, Span, Text, useLocalizations } from '@/customizables';
import {
Col,
descriptors,
Flow,
Grid,
localizationKeys,
RadioInput,
SimpleButton,
Span,
Text,
useLocalizations,
} from '@/customizables';
import { useCardState } from '@/elements/contexts';
import { common, mqu } from '@/styledSystem';
import { Alert } from '@/ui/elements/Alert';
Expand Down Expand Up @@ -95,7 +106,7 @@ export const SelectProviderStep = (): JSX.Element => {
key={group.id}
elementDescriptor={descriptors.configureSSOProviderGroup}
elementId={descriptors.configureSSOProviderGroup.setId(group.id)}
sx={theme => ({ gap: theme.space.$3 })}
gap={3}
>
<Text
elementDescriptor={descriptors.configureSSOProviderGroupLabel}
Expand Down Expand Up @@ -173,58 +184,44 @@ const ProviderCard = ({ name, value, iconId, label, checked, onChange }: Provide
const labelText = t(label);

return (
<Box
<SimpleButton
as='label'
variant='outline'
elementDescriptor={descriptors.configureSSOProviderCard}
elementId={descriptors.configureSSOProviderCard.setId(value)}
isActive={checked}
sx={theme => ({
// Outline-button look (mirrors SimpleButton variant='outline' for visual continuity).
borderWidth: theme.borderWidths.$normal,
borderStyle: theme.borderStyles.$solid,
borderColor: theme.colors.$borderAlpha150,
borderRadius: theme.radii.$md,
color: theme.colors.$neutralAlpha600,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: theme.space.$2,
height: theme.sizes.$32,
padding: theme.space.$1x5,
backgroundColor: theme.colors.$colorBackground,
cursor: 'pointer',
position: 'relative',
'&:hover': { backgroundColor: theme.colors.$neutralAlpha50 },
// Keyboard focus indication — fires when the inner input is focused.
cursor: 'pointer',
'&:has(input:focus-visible)': {
...common.focusRingStyles(theme),
borderColor: theme.colors.$borderAlpha300,
},
// Selected ring — CSS-driven via :checked so it survives focus changes.
'&:has(input:checked)': {
borderColor: theme.colors.$borderAlpha300,
...common.focusRingStyles(theme),
backgroundColor: theme.colors.$neutralAlpha50,
},
})}
>
<input
type='radio'
<RadioInput
name={name}
value={value}
checked={checked}
onChange={onChange}
css={{
focusRing={false}
sx={theme => ({
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0,0,0,0)',
whiteSpace: 'nowrap',
borderWidth: 0,
}}
top: theme.space.$1x5,
insetInlineStart: theme.space.$1x5,
margin: 0,
width: 'fit-content',
})}
/>

<Span
Expand Down Expand Up @@ -263,6 +260,6 @@ const ProviderCard = ({ name, value, iconId, label, checked, onChange }: Provide
>
{labelText}
</Text>
</Box>
</SimpleButton>
);
};
50 changes: 44 additions & 6 deletions packages/ui/src/primitives/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -251,16 +251,54 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, ref) =>
);
});

const SimpleButton = React.forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
const parsedProps: ButtonProps = { ...props, isDisabled: props.isDisabled || props.isLoading };
// @ts-ignore — `as` widens the element. Existing callers without `as` keep
// the `<button>` typing; passing `as='label'` switches to label semantics
// (no `type`/`disabled`/preventDefault) so the wrapped input drives state.
type SimpleButtonProps = ButtonProps & { as?: 'button' | 'label' };

const SimpleButton = React.forwardRef<HTMLButtonElement | HTMLLabelElement, SimpleButtonProps>((props, ref) => {
const parsedProps: SimpleButtonProps = { ...props, isDisabled: props.isDisabled || props.isLoading };

const {
as: As = 'button',
loadingText,
isDisabled,
hoverAsFocus,
children,
onClick: onClickProp,
...rest
} = filterProps(parsedProps) as SimpleButtonProps;

const { loadingText, isDisabled, hoverAsFocus, children, onClick: onClickProp, ...rest } = filterProps(parsedProps);
if (As === 'label') {
// Strip button-only props so the label doesn't receive `type`/`disabled`
// and clicks propagate naturally to the wrapped input.
const {
type: _type,
disabled: _disabled,
...labelRest
} = rest as React.LabelHTMLAttributes<HTMLLabelElement> & {
type?: unknown;
disabled?: unknown;
};
return (
<label
{...applyDataStateProps(labelRest)}
onClick={onClickProp as unknown as React.MouseEventHandler<HTMLLabelElement>}
css={applyVariants(parsedProps)}
data-variant={props.variant || 'solid'}
data-color={props.colorScheme || 'primary'}
ref={ref as React.Ref<HTMLLabelElement>}
>
{children}
</label>
);
}

const onClick: React.MouseEventHandler<HTMLButtonElement> = e => {
if (rest.type !== 'submit') {
if ((rest as React.ButtonHTMLAttributes<HTMLButtonElement>).type !== 'submit') {
e.preventDefault();
}
return onClickProp?.(e);
return (onClickProp as React.MouseEventHandler<HTMLButtonElement> | undefined)?.(e);
};

return (
Expand All @@ -275,7 +313,7 @@ const SimpleButton = React.forwardRef<HTMLButtonElement, ButtonProps>((props, re
disabled={isDisabled}
data-variant={props.variant || 'solid'}
data-color={props.colorScheme || 'primary'}
ref={ref}
ref={ref as React.Ref<HTMLButtonElement>}
>
{children}
</button>
Expand Down
Loading