diff --git a/.changeset/tasty-coats-tickle.md b/.changeset/tasty-coats-tickle.md new file mode 100644 index 00000000000..7abec1f6bac --- /dev/null +++ b/.changeset/tasty-coats-tickle.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +Introduce radio group for `EnableOrganizationsPrompt` diff --git a/packages/ui/src/components/devPrompts/EnableOrganizationsPrompt/index.tsx b/packages/ui/src/components/devPrompts/EnableOrganizationsPrompt/index.tsx index 0be43788a0e..91be476c648 100644 --- a/packages/ui/src/components/devPrompts/EnableOrganizationsPrompt/index.tsx +++ b/packages/ui/src/components/devPrompts/EnableOrganizationsPrompt/index.tsx @@ -1,16 +1,16 @@ -import { useClerk } from '@clerk/shared/react'; +import { createContextAndHook, useClerk } from '@clerk/shared/react'; import type { __internal_EnableOrganizationsPromptProps, EnableEnvironmentSettingParams } from '@clerk/shared/types'; // eslint-disable-next-line no-restricted-imports import type { SerializedStyles } from '@emotion/react'; // eslint-disable-next-line no-restricted-imports -import { css, type Theme } from '@emotion/react'; -import { forwardRef, useId, useLayoutEffect, useRef, useState } from 'react'; +import { css } from '@emotion/react'; +import React, { forwardRef, useId, useLayoutEffect, useRef, useState } from 'react'; import { useEnvironment } from '@/ui/contexts'; import { Modal } from '@/ui/elements/Modal'; -import { common, InternalThemeProvider } from '@/ui/styledSystem'; +import { InternalThemeProvider } from '@/ui/styledSystem'; -import { Box, Flex, Span } from '../../../customizables'; +import { Flex } from '../../../customizables'; import { Portal } from '../../../elements/Portal'; import { basePromptElementStyles, ClerkLogoIcon, PromptContainer, PromptSuccessIcon } from '../shared'; @@ -20,7 +20,7 @@ const EnableOrganizationsPromptInternal = ({ caller, onSuccess, onClose, -}: __internal_EnableOrganizationsPromptProps) => { +}: __internal_EnableOrganizationsPromptProps): JSX.Element => { const clerk = useClerk(); const [isLoading, setIsLoading] = useState(false); const [isEnabled, setIsEnabled] = useState(false); @@ -28,6 +28,7 @@ const EnableOrganizationsPromptInternal = ({ const initialFocusRef = useRef(null); const environment = useEnvironment(); + const radioGroupLabelId = useId(); const isComponent = !caller.startsWith('use'); @@ -140,6 +141,7 @@ const EnableOrganizationsPromptInternal = ({ ) : ( <>

{hasPersonalAccountsEnabled && ( - ({ - display: 'grid', - gridTemplateRows: isEnabled ? '0fr' : '1fr', - transition: `grid-template-rows ${t.transitionDuration.$slower} ${t.transitionTiming.$slowBezier}`, - marginInline: '-0.5rem', - overflow: 'hidden', - })} - {...(isEnabled && { inert: '' })} + ({ marginTop: t.sizes.$2 })} + direction='col' > - ({ - minHeight: 0, - paddingInline: '0.5rem', - opacity: isEnabled ? 0 : 1, - transition: `opacity ${t.transitionDuration.$slower} ${t.transitionTiming.$slowBezier}`, - })} + setAllowPersonalAccount(value === 'allow')} + labelledBy={radioGroupLabelId} > - ({ marginTop: t.sizes.$2 })}> - setAllowPersonalAccount(prev => !prev)} - /> - - - + + Require organization membership + Standard + + } + description='Users need to belong to at least one organization.' + /> + + + )} @@ -274,7 +274,7 @@ const EnableOrganizationsPromptInternal = ({ * A prompt that allows the user to enable the Organizations feature for their development instance * @internal */ -export const EnableOrganizationsPrompt = (props: __internal_EnableOrganizationsPromptProps) => { +export const EnableOrganizationsPrompt = (props: __internal_EnableOrganizationsPromptProps): JSX.Element => { return ( @@ -368,136 +368,206 @@ const PromptButton = forwardRef(({ variant ); }); -type SwitchProps = React.ComponentProps<'input'> & { - label: string; - description?: string; +type PromptBadgeProps = { + children: React.ReactNode; }; -const TRACK_PADDING = '2px'; -const TRACK_INNER_WIDTH = (t: Theme) => t.sizes.$6; -const TRACK_HEIGHT = (t: Theme) => t.sizes.$4; -const THUMB_WIDTH = (t: Theme) => t.sizes.$3; +const PromptBadge = ({ children }: PromptBadgeProps): JSX.Element => { + return ( + + {children} + + ); +}; -const Switch = forwardRef( - ({ label, description, checked: controlledChecked, defaultChecked, onChange, ...props }, ref) => { - const descriptionId = useId(); +type RadioGroupContextValue = { + name: string; + value: string; + onChange: (value: string) => void; +}; - const isControlled = controlledChecked !== undefined; - const [internalChecked, setInternalChecked] = useState(!!defaultChecked); - const checked = isControlled ? controlledChecked : internalChecked; +const [RadioGroupContext, useRadioGroup] = createContextAndHook('RadioGroupContext'); - const handleChange = (e: React.ChangeEvent) => { - if (!isControlled) { - setInternalChecked(e.target.checked); - } - onChange?.(e); - }; +type RadioGroupProps = { + value: string; + onChange: (value: string) => void; + children: React.ReactNode; + labelledBy?: string; +}; - return ( +const RadioGroup = ({ value, onChange, children, labelledBy }: RadioGroupProps): JSX.Element => { + const name = useId(); + const contextValue = React.useMemo(() => ({ value: { name, value, onChange } }), [name, value, onChange]); + + return ( + - input + span': { - outline: '2px solid white', - outlineOffset: '2px', - }, - '&:has(input:disabled) > input + span': { - opacity: 0.6, - cursor: 'not-allowed', - pointerEvents: 'none', - }, - }} - > - - { - const trackWidth = `calc(${TRACK_INNER_WIDTH(t)} + ${TRACK_PADDING} + ${TRACK_PADDING})`; - const trackHeight = `calc(${TRACK_HEIGHT(t)} + ${TRACK_PADDING})`; - return { - display: 'flex', - alignItems: 'center', - paddingInline: TRACK_PADDING, - width: trackWidth, - height: trackHeight, - border: '1px solid rgba(255, 255, 255, 0.2)', - backgroundColor: checked ? '#6C47FF' : 'rgba(0, 0, 0, 0.2)', - borderRadius: 999, - transition: 'background-color 0.2s ease-in-out', - }; - }} - > - { - const size = THUMB_WIDTH(t); - const maxTranslateX = `calc(${TRACK_INNER_WIDTH(t)} - ${size} - ${TRACK_PADDING})`; - return { - width: size, - height: size, - borderRadius: 9999, - backgroundColor: 'white', - boxShadow: '0px 0px 0px 1px rgba(0, 0, 0, 0.1)', - transform: `translateX(${checked ? maxTranslateX : '0'})`, - transition: 'transform 0.2s ease-in-out', - '@media (prefers-reduced-motion: reduce)': { - transition: 'none', - }, - }; - }} - /> - - - {label} - - - {description ? ( - [ - basePromptElementStyles, - { - display: 'block', - paddingInlineStart: `calc(${TRACK_INNER_WIDTH(t)} + ${TRACK_PADDING} + ${TRACK_PADDING} + ${t.sizes.$2})`, - fontSize: '0.75rem', - lineHeight: '1.3333333333', - color: '#c3c3c6', - textWrap: 'pretty', - }, - ]} - > - {description} - - ) : null} + {children} - ); - }, -); + + ); +}; + +type RadioGroupItemProps = { + value: string; + label: React.ReactNode; + description?: React.ReactNode; +}; + +const RADIO_INDICATOR_SIZE = '1rem'; +const RADIO_GAP = '0.5rem'; + +const RadioGroupItem = ({ value, label, description }: RadioGroupItemProps): JSX.Element => { + const { name, value: selectedValue, onChange } = useRadioGroup(); + const descriptionId = useId(); + const checked = value === selectedValue; + + return ( + + + + {description && ( + + {description} + + )} + + ); +}; const Link = forwardRef & { css?: SerializedStyles }>( ({ children, css: cssProp, ...props }, ref) => { @@ -524,7 +594,7 @@ const Link = forwardRef & { css?: S }, ); -const CoinFlip = ({ isEnabled }: { isEnabled: boolean }) => { +const CoinFlip = ({ isEnabled }: { isEnabled: boolean }): JSX.Element => { const [rotation, setRotation] = useState(0); useLayoutEffect(() => {