Skip to content

Commit bd96ed0

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

File tree

2 files changed

+130
-120
lines changed

2 files changed

+130
-120
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: 125 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1-
import { useClerk } from '@clerk/shared/react';
1+
import { createContextAndHook, useClerk } from '@clerk/shared/react';
22
import type { __internal_EnableOrganizationsPromptProps, EnableEnvironmentSettingParams } from '@clerk/shared/types';
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,136 +377,132 @@ const PromptButton = forwardRef<HTMLButtonElement, PromptButtonProps>(({ variant
368377
);
369378
});
370379

371-
type SwitchProps = React.ComponentProps<'input'> & {
380+
type RadioGroupContextValue = {
381+
name: string;
382+
value: string;
383+
onChange: (value: string) => void;
384+
};
385+
386+
const [RadioGroupContext, useRadioGroup] = createContextAndHook<RadioGroupContextValue>('RadioGroupContext');
387+
388+
type RadioGroupProps = {
389+
value: string;
390+
onChange: (value: string) => void;
391+
children: React.ReactNode;
392+
};
393+
394+
const RadioGroup = ({ value, onChange, children }: RadioGroupProps) => {
395+
const name = useId();
396+
const contextValue = React.useMemo(() => ({ value: { name, value, onChange } }), [name, value, onChange]);
397+
398+
return (
399+
<RadioGroupContext.Provider value={contextValue}>
400+
<Flex
401+
direction='col'
402+
gap={3}
403+
>
404+
{children}
405+
</Flex>
406+
</RadioGroupContext.Provider>
407+
);
408+
};
409+
410+
type RadioGroupItemProps = {
411+
value: string;
372412
label: string;
373413
description?: string;
374414
};
375415

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;
380-
381-
const Switch = forwardRef<HTMLInputElement, SwitchProps>(
382-
({ label, description, checked: controlledChecked, defaultChecked, onChange, ...props }, ref) => {
383-
const descriptionId = useId();
416+
const RadioGroupItem = ({ value, label, description }: RadioGroupItemProps) => {
417+
const { name, value: selectedValue, onChange } = useRadioGroup();
418+
const descriptionId = useId();
419+
const checked = value === selectedValue;
384420

385-
const isControlled = controlledChecked !== undefined;
386-
const [internalChecked, setInternalChecked] = useState(!!defaultChecked);
387-
const checked = isControlled ? controlledChecked : internalChecked;
421+
return (
422+
<Flex
423+
as='label'
424+
gap={2}
425+
align='start'
426+
sx={{
427+
cursor: 'pointer',
428+
userSelect: 'none',
429+
}}
430+
>
431+
<input
432+
type='radio'
433+
name={name}
434+
value={value}
435+
checked={checked}
436+
onChange={() => onChange(value)}
437+
aria-describedby={description ? descriptionId : undefined}
438+
css={css`
439+
${basePromptElementStyles};
440+
appearance: none;
441+
width: 1rem;
442+
height: 1rem;
443+
margin: 0;
444+
margin-top: 0.125rem;
445+
border: 1px solid rgba(255, 255, 255, 0.3);
446+
border-radius: 50%;
447+
background-color: transparent;
448+
cursor: pointer;
449+
flex-shrink: 0;
450+
transition: 120ms ease-in-out;
451+
transition-property: border-color, background-color, box-shadow;
452+
453+
&:checked {
454+
border-color: #fff;
455+
background-color: #fff;
456+
box-shadow: inset 0 0 0 3px #1f1f1f;
457+
}
388458
389-
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
390-
if (!isControlled) {
391-
setInternalChecked(e.target.checked);
392-
}
393-
onChange?.(e);
394-
};
459+
&:focus-visible {
460+
outline: 2px solid white;
461+
outline-offset: 2px;
462+
}
395463
396-
return (
464+
&:hover:not(:checked) {
465+
border-color: rgba(255, 255, 255, 0.5);
466+
}
467+
`}
468+
/>
397469
<Flex
398470
direction='col'
399471
gap={1}
400472
>
401-
<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-
}}
473+
<span
474+
css={[
475+
basePromptElementStyles,
476+
css`
477+
font-size: 0.875rem;
478+
font-weight: 500;
479+
line-height: 1.25;
480+
color: white;
481+
`,
482+
]}
418483
>
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>
484+
{label}
485+
</span>
486+
{description && (
465487
<span
488+
id={descriptionId}
466489
css={[
467490
basePromptElementStyles,
468491
css`
469-
font-size: 0.875rem;
470-
font-weight: 500;
471-
line-height: 1.25;
472-
color: white;
492+
font-size: 0.75rem;
493+
line-height: 1.3333333333;
494+
color: #c3c3c6;
495+
text-wrap: pretty;
473496
`,
474497
]}
475-
>
476-
{label}
477-
</span>
478-
</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-
]}
493498
>
494499
{description}
495-
</Span>
496-
) : null}
500+
</span>
501+
)}
497502
</Flex>
498-
);
499-
},
500-
);
503+
</Flex>
504+
);
505+
};
501506

502507
const Link = forwardRef<HTMLAnchorElement, React.ComponentProps<'a'> & { css?: SerializedStyles }>(
503508
({ children, css: cssProp, ...props }, ref) => {

0 commit comments

Comments
 (0)