Skip to content

Commit 98202ad

Browse files
committed
Add first draft for radio group
1 parent cfd0cba commit 98202ad

File tree

2 files changed

+126
-111
lines changed

2 files changed

+126
-111
lines changed

.changeset/tasty-coats-tickle.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/ui': patch
3+
---
4+
5+
Introduce radio group for `EnableOrganizationsPrompt`

packages/ui/src/components/devPrompts/EnableOrganizationsPrompt/index.tsx

Lines changed: 121 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@ import type { __internal_EnableOrganizationsPromptProps, EnableEnvironmentSettin
33
// eslint-disable-next-line no-restricted-imports
44
import type { SerializedStyles } from '@emotion/react';
55
// eslint-disable-next-line no-restricted-imports
6-
import { css, type Theme } from '@emotion/react';
7-
import { forwardRef, useId, useLayoutEffect, useRef, useState } from 'react';
6+
import { css } from '@emotion/react';
7+
import React, { forwardRef, useId, useLayoutEffect, useRef, useState } from 'react';
88

99
import { useEnvironment } from '@/ui/contexts';
1010
import { Modal } from '@/ui/elements/Modal';
11-
import { common, InternalThemeProvider } from '@/ui/styledSystem';
11+
import { InternalThemeProvider } from '@/ui/styledSystem';
1212

13-
import { Box, Flex, Span } from '../../../customizables';
13+
import { Box, Flex } from '../../../customizables';
1414
import { Portal } from '../../../elements/Portal';
1515
import { basePromptElementStyles, ClerkLogoIcon, PromptContainer, PromptSuccessIcon } from '../shared';
1616

@@ -197,12 +197,21 @@ const EnableOrganizationsPromptInternal = ({
197197
})}
198198
>
199199
<Flex sx={t => ({ marginTop: t.sizes.$2 })}>
200-
<Switch
201-
label='Allow personal account'
202-
description='Allow users to work outside of an organization by providing a personal account. We do not recommend for B2B SaaS apps.'
203-
checked={allowPersonalAccount}
204-
onChange={() => setAllowPersonalAccount(prev => !prev)}
205-
/>
200+
<RadioGroup
201+
value={allowPersonalAccount ? 'allow' : 'require'}
202+
onChange={value => setAllowPersonalAccount(value === 'allow')}
203+
>
204+
<RadioGroupItem
205+
value='require'
206+
label='Require organization membership'
207+
description='Users will be required to create or join an organization to access the application. Common for most B2B SaaS applications.'
208+
/>
209+
<RadioGroupItem
210+
value='allow'
211+
label='Allow personal accounts'
212+
description='Users will be able to work outside of an organization by providing a personal account.'
213+
/>
214+
</RadioGroup>
206215
</Flex>
207216
</Flex>
208217
</Box>
@@ -368,100 +377,103 @@ const PromptButton = forwardRef<HTMLButtonElement, PromptButtonProps>(({ variant
368377
);
369378
});
370379

371-
type SwitchProps = React.ComponentProps<'input'> & {
372-
label: string;
373-
description?: string;
380+
type RadioGroupProps = {
381+
value: string;
382+
onChange: (value: string) => void;
383+
children: React.ReactNode;
374384
};
375385

376-
const TRACK_PADDING = '2px';
377-
const TRACK_INNER_WIDTH = (t: Theme) => t.sizes.$6;
378-
const TRACK_HEIGHT = (t: Theme) => t.sizes.$4;
379-
const THUMB_WIDTH = (t: Theme) => t.sizes.$3;
386+
const RadioGroup = ({ value, onChange, children }: RadioGroupProps) => {
387+
const name = useId();
380388

381-
const Switch = forwardRef<HTMLInputElement, SwitchProps>(
382-
({ label, description, checked: controlledChecked, defaultChecked, onChange, ...props }, ref) => {
383-
const descriptionId = useId();
389+
return (
390+
<Flex
391+
role='radiogroup'
392+
direction='col'
393+
gap={3}
394+
>
395+
{React.Children.map(children, child => {
396+
if (React.isValidElement<RadioGroupItemProps>(child)) {
397+
return React.cloneElement(child, {
398+
name,
399+
checked: child.props.value === value,
400+
onChange: () => onChange(child.props.value),
401+
});
402+
}
403+
return child;
404+
})}
405+
</Flex>
406+
);
407+
};
384408

385-
const isControlled = controlledChecked !== undefined;
386-
const [internalChecked, setInternalChecked] = useState(!!defaultChecked);
387-
const checked = isControlled ? controlledChecked : internalChecked;
409+
type RadioGroupItemProps = {
410+
value: string;
411+
label: string;
412+
description?: string;
413+
name?: string;
414+
checked?: boolean;
415+
onChange?: () => void;
416+
};
388417

389-
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
390-
if (!isControlled) {
391-
setInternalChecked(e.target.checked);
392-
}
393-
onChange?.(e);
394-
};
418+
const RadioGroupItem = forwardRef<HTMLInputElement, RadioGroupItemProps>(
419+
({ value, label, description, name, checked, onChange }, ref) => {
420+
const id = useId();
421+
const descriptionId = useId();
395422

396423
return (
397424
<Flex
398-
direction='col'
399-
gap={1}
425+
as='label'
426+
gap={2}
427+
align='start'
428+
sx={{
429+
cursor: 'pointer',
430+
userSelect: 'none',
431+
}}
400432
>
433+
<input
434+
ref={ref}
435+
type='radio'
436+
id={id}
437+
name={name}
438+
value={value}
439+
checked={checked}
440+
onChange={onChange}
441+
aria-describedby={description ? descriptionId : undefined}
442+
css={css`
443+
${basePromptElementStyles};
444+
appearance: none;
445+
width: 1rem;
446+
height: 1rem;
447+
margin: 0;
448+
margin-top: 0.125rem;
449+
border: 1px solid rgba(255, 255, 255, 0.3);
450+
border-radius: 50%;
451+
background-color: transparent;
452+
cursor: pointer;
453+
flex-shrink: 0;
454+
transition: 120ms ease-in-out;
455+
transition-property: border-color, background-color, box-shadow;
456+
457+
&:checked {
458+
border-color: #fff;
459+
background-color: #fff;
460+
box-shadow: inset 0 0 0 3px #1f1f1f;
461+
}
462+
463+
&:focus-visible {
464+
outline: 2px solid white;
465+
outline-offset: 2px;
466+
}
467+
468+
&:hover:not(:checked) {
469+
border-color: rgba(255, 255, 255, 0.5);
470+
}
471+
`}
472+
/>
401473
<Flex
402-
as='label'
403-
gap={2}
404-
align='center'
405-
sx={{
406-
isolation: 'isolate',
407-
userSelect: 'none',
408-
'&:has(input:focus-visible) > input + span': {
409-
outline: '2px solid white',
410-
outlineOffset: '2px',
411-
},
412-
'&:has(input:disabled) > input + span': {
413-
opacity: 0.6,
414-
cursor: 'not-allowed',
415-
pointerEvents: 'none',
416-
},
417-
}}
474+
direction='col'
475+
gap={1}
418476
>
419-
<input
420-
type='checkbox'
421-
{...props}
422-
ref={ref}
423-
role='switch'
424-
{...(isControlled ? { checked } : { defaultChecked })}
425-
onChange={handleChange}
426-
css={{ ...common.visuallyHidden() }}
427-
aria-describedby={description ? descriptionId : undefined}
428-
/>
429-
<Span
430-
sx={t => {
431-
const trackWidth = `calc(${TRACK_INNER_WIDTH(t)} + ${TRACK_PADDING} + ${TRACK_PADDING})`;
432-
const trackHeight = `calc(${TRACK_HEIGHT(t)} + ${TRACK_PADDING})`;
433-
return {
434-
display: 'flex',
435-
alignItems: 'center',
436-
paddingInline: TRACK_PADDING,
437-
width: trackWidth,
438-
height: trackHeight,
439-
border: '1px solid rgba(255, 255, 255, 0.2)',
440-
backgroundColor: checked ? '#6C47FF' : 'rgba(0, 0, 0, 0.2)',
441-
borderRadius: 999,
442-
transition: 'background-color 0.2s ease-in-out',
443-
};
444-
}}
445-
>
446-
<Span
447-
sx={t => {
448-
const size = THUMB_WIDTH(t);
449-
const maxTranslateX = `calc(${TRACK_INNER_WIDTH(t)} - ${size} - ${TRACK_PADDING})`;
450-
return {
451-
width: size,
452-
height: size,
453-
borderRadius: 9999,
454-
backgroundColor: 'white',
455-
boxShadow: '0px 0px 0px 1px rgba(0, 0, 0, 0.1)',
456-
transform: `translateX(${checked ? maxTranslateX : '0'})`,
457-
transition: 'transform 0.2s ease-in-out',
458-
'@media (prefers-reduced-motion: reduce)': {
459-
transition: 'none',
460-
},
461-
};
462-
}}
463-
/>
464-
</Span>
465477
<span
466478
css={[
467479
basePromptElementStyles,
@@ -475,25 +487,23 @@ const Switch = forwardRef<HTMLInputElement, SwitchProps>(
475487
>
476488
{label}
477489
</span>
490+
{description && (
491+
<span
492+
id={descriptionId}
493+
css={[
494+
basePromptElementStyles,
495+
css`
496+
font-size: 0.75rem;
497+
line-height: 1.3333333333;
498+
color: #c3c3c6;
499+
text-wrap: pretty;
500+
`,
501+
]}
502+
>
503+
{description}
504+
</span>
505+
)}
478506
</Flex>
479-
{description ? (
480-
<Span
481-
id={descriptionId}
482-
sx={t => [
483-
basePromptElementStyles,
484-
{
485-
display: 'block',
486-
paddingInlineStart: `calc(${TRACK_INNER_WIDTH(t)} + ${TRACK_PADDING} + ${TRACK_PADDING} + ${t.sizes.$2})`,
487-
fontSize: '0.75rem',
488-
lineHeight: '1.3333333333',
489-
color: '#c3c3c6',
490-
textWrap: 'pretty',
491-
},
492-
]}
493-
>
494-
{description}
495-
</Span>
496-
) : null}
497507
</Flex>
498508
);
499509
},

0 commit comments

Comments
 (0)