diff --git a/.changeset/slow-wombats-battle.md b/.changeset/slow-wombats-battle.md
new file mode 100644
index 00000000000..7438ec655c1
--- /dev/null
+++ b/.changeset/slow-wombats-battle.md
@@ -0,0 +1,5 @@
+---
+'@clerk/clerk-js': patch
+---
+
+Refactor of internal radio input in forms.
diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/VerifiedDomainPage.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/VerifiedDomainPage.tsx
index 02be6245755..2dc351302bf 100644
--- a/packages/clerk-js/src/ui/components/OrganizationProfile/VerifiedDomainPage.tsx
+++ b/packages/clerk-js/src/ui/components/OrganizationProfile/VerifiedDomainPage.tsx
@@ -1,4 +1,8 @@
-import type { OrganizationDomainResource, OrganizationEnrollmentMode } from '@clerk/types';
+import type {
+ OrganizationDomainResource,
+ OrganizationEnrollmentMode,
+ OrganizationSettingsResource,
+} from '@clerk/types';
import { CalloutWithAction, useGate } from '../../common';
import { useCoreOrganization, useEnvironment } from '../../contexts';
@@ -51,6 +55,46 @@ const useCalloutLabel = (
];
};
+const buildEnrollmentOptions = (settings: OrganizationSettingsResource) => {
+ const _options = [];
+ if (settings.domains.enrollmentModes.includes('manual_invitation')) {
+ _options.push({
+ value: 'manual_invitation',
+ label: localizationKeys('organizationProfile.verifiedDomainPage.enrollmentTab.manualInvitationOption__label'),
+ description: localizationKeys(
+ 'organizationProfile.verifiedDomainPage.enrollmentTab.manualInvitationOption__description',
+ ),
+ });
+ }
+
+ if (settings.domains.enrollmentModes.includes('automatic_invitation')) {
+ _options.push({
+ value: 'automatic_invitation',
+ label: localizationKeys('organizationProfile.verifiedDomainPage.enrollmentTab.automaticInvitationOption__label'),
+ description: localizationKeys(
+ 'organizationProfile.verifiedDomainPage.enrollmentTab.automaticInvitationOption__description',
+ ),
+ });
+ }
+
+ if (settings.domains.enrollmentModes.includes('automatic_suggestion')) {
+ _options.push({
+ value: 'automatic_suggestion',
+ label: localizationKeys('organizationProfile.verifiedDomainPage.enrollmentTab.automaticSuggestionOption__label'),
+ description: localizationKeys(
+ 'organizationProfile.verifiedDomainPage.enrollmentTab.automaticSuggestionOption__description',
+ ),
+ });
+ }
+
+ return _options;
+};
+
+const useEnrollmentOptions = () => {
+ const { organizationSettings } = useEnvironment();
+ return buildEnrollmentOptions(organizationSettings);
+};
+
export const VerifiedDomainPage = withCardStateProvider(() => {
const card = useCardState();
const { organizationSettings } = useEnvironment();
@@ -71,49 +115,11 @@ export const VerifiedDomainPage = withCardStateProvider(() => {
const breadcrumbTitle = localizationKeys('organizationProfile.profilePage.domainSection.title');
const allowsEdit = mode === 'edit';
+ const enrollmentOptions = useEnrollmentOptions();
const enrollmentMode = useFormControl('enrollmentMode', '', {
type: 'radio',
- radioOptions: [
- ...(organizationSettings.domains.enrollmentModes.includes('manual_invitation')
- ? [
- {
- value: 'manual_invitation',
- label: localizationKeys(
- 'organizationProfile.verifiedDomainPage.enrollmentTab.manualInvitationOption__label',
- ),
- description: localizationKeys(
- 'organizationProfile.verifiedDomainPage.enrollmentTab.manualInvitationOption__description',
- ),
- },
- ]
- : []),
- ...(organizationSettings.domains.enrollmentModes.includes('automatic_invitation')
- ? [
- {
- value: 'automatic_invitation',
- label: localizationKeys(
- 'organizationProfile.verifiedDomainPage.enrollmentTab.automaticInvitationOption__label',
- ),
- description: localizationKeys(
- 'organizationProfile.verifiedDomainPage.enrollmentTab.automaticInvitationOption__description',
- ),
- },
- ]
- : []),
- ...(organizationSettings.domains.enrollmentModes.includes('automatic_suggestion')
- ? [
- {
- value: 'automatic_suggestion',
- label: localizationKeys(
- 'organizationProfile.verifiedDomainPage.enrollmentTab.automaticSuggestionOption__label',
- ),
- description: localizationKeys(
- 'organizationProfile.verifiedDomainPage.enrollmentTab.automaticSuggestionOption__description',
- ),
- },
- ]
- : []),
- ],
+ radioOptions: enrollmentOptions,
+ isRequired: true,
});
const deletePending = useFormControl('deleteExistingInvitationsSuggestions', '', {
@@ -252,7 +258,7 @@ export const VerifiedDomainPage = withCardStateProvider(() => {
gap={6}
>
-
+
{allowsEdit && (
diff --git a/packages/clerk-js/src/ui/elements/FieldControl.tsx b/packages/clerk-js/src/ui/elements/FieldControl.tsx
index 55d293dcaf6..cebfec38198 100644
--- a/packages/clerk-js/src/ui/elements/FieldControl.tsx
+++ b/packages/clerk-js/src/ui/elements/FieldControl.tsx
@@ -22,6 +22,7 @@ import { useFormControlFeedback } from '../utils';
import { useCardState } from './contexts';
import type { FormFeedbackProps } from './FormControl';
import { FormFeedback } from './FormControl';
+import { RadioItem } from './RadioGroup';
type FormControlProps = Omit, 'label' | 'placeholder' | 'disabled' | 'required'> &
ReturnType>['props'];
@@ -218,6 +219,7 @@ export const Field = {
Label: FieldLabel,
LabelRow: FieldLabelRow,
Input: InputElement,
+ RadioItem: RadioItem,
Action: FieldAction,
AsOptional: FieldOptionalLabel,
LabelIcon: FieldLabelIcon,
diff --git a/packages/clerk-js/src/ui/elements/Form.tsx b/packages/clerk-js/src/ui/elements/Form.tsx
index 90f6d433838..aded9dfa076 100644
--- a/packages/clerk-js/src/ui/elements/Form.tsx
+++ b/packages/clerk-js/src/ui/elements/Form.tsx
@@ -4,7 +4,7 @@ import type { PropsWithChildren } from 'react';
import React, { useState } from 'react';
import type { LocalizationKey } from '../customizables';
-import { Button, descriptors, Flex, Form as FormPrim, localizationKeys } from '../customizables';
+import { Button, Col, descriptors, Flex, Form as FormPrim, localizationKeys } from '../customizables';
import { useLoadingStatus } from '../hooks';
import type { PropsOfComponent } from '../styledSystem';
import { useCardState } from './contexts';
@@ -76,35 +76,31 @@ const FormRoot = (props: FormProps): JSX.Element => {
const FormSubmit = (props: PropsOfComponent) => {
const { isLoading, isDisabled } = useFormState();
return (
- <>
-
- >
+
);
};
const FormReset = (props: PropsOfComponent) => {
const { isLoading, isDisabled } = useFormState();
return (
- <>
-
- >
+
);
};
@@ -160,6 +156,31 @@ const PlainInput = (props: CommonInputProps) => {
);
};
+const RadioGroup = (
+ props: Omit, 'infoText' | 'type' | 'validatePassword' | 'label' | 'placeholder'>,
+) => {
+ const { radioOptions, ...fieldProps } = props;
+ return (
+
+
+ {radioOptions?.map(({ value, description, label }) => (
+
+ ))}
+
+
+
+
+ );
+};
+
export const Form = {
Root: FormRoot,
ControlRow: FormControlRow,
@@ -168,6 +189,7 @@ export const Form = {
*/
Control: FormControl,
PlainInput,
+ RadioGroup,
SubmitButton: FormSubmit,
ResetButton: FormReset,
};
diff --git a/packages/clerk-js/src/ui/elements/RadioGroup.tsx b/packages/clerk-js/src/ui/elements/RadioGroup.tsx
index f8be9e18b85..2d857c0ce19 100644
--- a/packages/clerk-js/src/ui/elements/RadioGroup.tsx
+++ b/packages/clerk-js/src/ui/elements/RadioGroup.tsx
@@ -1,9 +1,13 @@
-import { useId } from 'react';
+import { forwardRef, useId } from 'react';
import type { LocalizationKey } from '../customizables';
import { Col, descriptors, Flex, FormLabel, Input, Text } from '../customizables';
+import { sanitizeInputProps, useFormField } from '../primitives/hooks';
import type { PropsOfComponent } from '../styledSystem';
+/**
+ * @deprecated
+ */
export const RadioGroup = (
props: PropsOfComponent & {
radioOptions?: {
@@ -30,6 +34,9 @@ export const RadioGroup = (
);
};
+/**
+ * @deprecated
+ */
const RadioGroupItem = (props: {
inputProps: PropsOfComponent;
value: string;
@@ -86,3 +93,87 @@ const RadioGroupItem = (props: {
);
};
+
+const RadioIndicator = forwardRef((props, ref) => {
+ const formField = useFormField();
+ const { value, placeholder, ...inputProps } = sanitizeInputProps(formField);
+
+ return (
+ ({
+ width: 'fit-content',
+ marginTop: t.space.$0x5,
+ })}
+ type='radio'
+ value={props.value}
+ checked={props.value === value}
+ />
+ );
+});
+
+export const RadioLabel = (props: {
+ label: string | LocalizationKey;
+ description?: string | LocalizationKey;
+ id?: string;
+}) => {
+ return (
+ ({
+ padding: `${t.space.$none} ${t.space.$2}`,
+ display: 'flex',
+ flexDirection: 'column',
+ })}
+ >
+
+
+ {props.description && (
+
+ )}
+
+ );
+};
+
+export const RadioItem = forwardRef<
+ HTMLInputElement,
+ {
+ value: string;
+ label: string | LocalizationKey;
+ description?: string | LocalizationKey;
+ }
+>((props, ref) => {
+ const randomId = useId();
+ return (
+
+
+
+
+
+ );
+});
diff --git a/packages/clerk-js/src/ui/elements/__tests__/RadioGroup.test.tsx b/packages/clerk-js/src/ui/elements/__tests__/RadioGroup.test.tsx
new file mode 100644
index 00000000000..aafcca099d3
--- /dev/null
+++ b/packages/clerk-js/src/ui/elements/__tests__/RadioGroup.test.tsx
@@ -0,0 +1,351 @@
+import { describe, it } from '@jest/globals';
+import { act, fireEvent, render, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { useFormControl } from '../../utils';
+import { bindCreateFixtures } from '../../utils/test/createFixtures';
+import { withCardStateProvider } from '../contexts';
+import { Form } from '../Form';
+
+const { createFixtures } = bindCreateFixtures('UserProfile');
+const createField = (...params: Parameters) => {
+ const MockFieldWrapper = withCardStateProvider((props: Partial[0]>) => {
+ const field = useFormControl(...params);
+
+ return (
+ <>
+ {/* @ts-ignore*/}
+
+
+ >
+ );
+ });
+
+ return {
+ Field: MockFieldWrapper,
+ };
+};
+
+// TODO: Remove this once FormControl is no longer used
+const createFormControl = (...params: Parameters) => {
+ const MockFieldWrapper = withCardStateProvider((props: Partial[0]>) => {
+ const field = useFormControl(...params);
+
+ return (
+ <>
+ {/* @ts-ignore*/}
+
+
+ >
+ );
+ });
+
+ return {
+ Field: MockFieldWrapper,
+ };
+};
+
+describe('RadioGroup', () => {
+ it('renders the component', async () => {
+ const { wrapper } = await createFixtures();
+ const { Field } = createField('some-radio', '');
+
+ const { getAllByRole } = render(
+ ,
+ { wrapper },
+ );
+
+ const radios = getAllByRole('radio');
+ expect(radios[0]).toHaveAttribute('value', 'one');
+ expect(radios[0].nextSibling).toHaveTextContent('One');
+ expect(radios[1]).toHaveAttribute('value', 'two');
+ expect(radios[1].nextSibling).toHaveTextContent('Two');
+
+ radios.forEach(radio => {
+ expect(radio).not.toBeChecked();
+ expect(radio).toHaveAttribute('name', 'some-radio');
+ expect(radio).not.toHaveAttribute('required');
+ expect(radio).not.toHaveAttribute('disabled');
+ });
+
+ expect(radios[1]).not.toBeChecked();
+ });
+
+ it('renders the component with default value', async () => {
+ const { wrapper } = await createFixtures();
+ const { Field } = createField('some-radio', 'two');
+
+ const { getAllByRole } = render(
+ ,
+ { wrapper },
+ );
+
+ const radios = getAllByRole('radio');
+ expect(radios[0]).toHaveAttribute('value', 'one');
+ expect(radios[0].nextSibling).toHaveTextContent('One');
+ expect(radios[1]).toHaveAttribute('value', 'two');
+ expect(radios[1].nextSibling).toHaveTextContent('Two');
+
+ radios.forEach(radio => {
+ expect(radio).toHaveAttribute('type', 'radio');
+ expect(radio).not.toHaveAttribute('required');
+ expect(radio).not.toHaveAttribute('disabled');
+ expect(radio).toHaveAttribute('aria-required', 'false');
+ expect(radio).toHaveAttribute('aria-disabled', 'false');
+ });
+
+ expect(radios[0]).not.toBeChecked();
+ expect(radios[1]).toBeChecked();
+ });
+
+ it('disabled', async () => {
+ const { wrapper } = await createFixtures();
+ const { Field } = createField('some-radio', 'two');
+
+ const { getAllByRole } = render(
+ ,
+ { wrapper },
+ );
+
+ const radios = getAllByRole('radio');
+ radios.forEach(radio => {
+ expect(radio).not.toHaveAttribute('required');
+ expect(radio).toHaveAttribute('disabled');
+ expect(radio).toHaveAttribute('aria-disabled', 'true');
+ });
+ });
+
+ it('required', async () => {
+ const { wrapper } = await createFixtures();
+ const { Field } = createField('some-radio', 'two');
+
+ const { getAllByRole } = render(
+ ,
+ { wrapper },
+ );
+
+ const radios = getAllByRole('radio');
+ radios.forEach(radio => {
+ expect(radio).toHaveAttribute('required');
+ expect(radio).toHaveAttribute('aria-required', 'true');
+ });
+ });
+
+ it('with error', async () => {
+ const { wrapper } = await createFixtures();
+ const { Field } = createField('some-radio', 'two');
+
+ const { getAllByRole, getByRole, getByText } = render(
+ ,
+ { wrapper },
+ );
+
+ await act(() => userEvent.click(getByRole('button', { name: /set error/i })));
+
+ await waitFor(() => {
+ const radios = getAllByRole('radio');
+ radios.forEach(radio => {
+ expect(radio).toHaveAttribute('aria-invalid', 'true');
+ expect(radio).toHaveAttribute('aria-describedby', 'error-some-radio');
+ });
+ expect(getByText('some error')).toBeInTheDocument();
+ });
+ });
+
+ it('with info', async () => {
+ const { wrapper } = await createFixtures();
+ const { Field } = createField('some-radio', '', {
+ type: 'radio',
+ radioOptions: [
+ { value: 'one', label: 'One' },
+ { value: 'two', label: 'Two' },
+ ],
+ infoText: 'some info',
+ });
+
+ const { getByLabelText, getByText } = render(, { wrapper });
+
+ await act(() => fireEvent.focus(getByLabelText('One')));
+ await waitFor(() => {
+ expect(getByText('some info')).toBeInTheDocument();
+ });
+ });
+});
+
+/**
+ * This tests ensure that the deprecated FormControl and RadioGroup continue to behave the same and nothing broke during the refactoring.
+ */
+describe('Form control as text', () => {
+ it('renders the component', async () => {
+ const { wrapper } = await createFixtures();
+ const { Field } = createFormControl('some-radio', '', {
+ type: 'radio',
+ radioOptions: [
+ { value: 'one', label: 'One' },
+ { value: 'two', label: 'Two' },
+ ],
+ });
+
+ const { getAllByRole } = render(, { wrapper });
+
+ const radios = getAllByRole('radio');
+ expect(radios[0]).toHaveAttribute('value', 'one');
+ expect(radios[0].nextSibling).toHaveTextContent('One');
+ expect(radios[1]).toHaveAttribute('value', 'two');
+ expect(radios[1].nextSibling).toHaveTextContent('Two');
+
+ radios.forEach(radio => {
+ expect(radio).not.toBeChecked();
+ expect(radio).toHaveAttribute('name', 'some-radio');
+ expect(radio).not.toHaveAttribute('required');
+ expect(radio).not.toHaveAttribute('disabled');
+ });
+
+ expect(radios[1]).not.toBeChecked();
+ });
+
+ it('renders the component with default value', async () => {
+ const { wrapper } = await createFixtures();
+ const { Field } = createFormControl('some-radio', 'two', {
+ type: 'radio',
+ radioOptions: [
+ { value: 'one', label: 'One' },
+ { value: 'two', label: 'Two' },
+ ],
+ });
+
+ const { getAllByRole } = render(, { wrapper });
+
+ const radios = getAllByRole('radio');
+ expect(radios[0]).toHaveAttribute('value', 'one');
+ expect(radios[0].nextSibling).toHaveTextContent('One');
+ expect(radios[1]).toHaveAttribute('value', 'two');
+ expect(radios[1].nextSibling).toHaveTextContent('Two');
+
+ radios.forEach(radio => {
+ expect(radio).toHaveAttribute('type', 'radio');
+ expect(radio).not.toHaveAttribute('required');
+ expect(radio).not.toHaveAttribute('disabled');
+ expect(radio).toHaveAttribute('aria-required', 'false');
+ expect(radio).toHaveAttribute('aria-disabled', 'false');
+ });
+
+ expect(radios[0]).not.toBeChecked();
+ expect(radios[1]).toBeChecked();
+ });
+
+ it('disabled', async () => {
+ const { wrapper } = await createFixtures();
+ const { Field } = createFormControl('some-radio', 'two', {
+ type: 'radio',
+ radioOptions: [
+ { value: 'one', label: 'One' },
+ { value: 'two', label: 'Two' },
+ ],
+ });
+
+ const { getAllByRole } = render(, { wrapper });
+
+ const radios = getAllByRole('radio');
+ radios.forEach(radio => {
+ expect(radio).not.toHaveAttribute('required');
+ expect(radio).toHaveAttribute('disabled');
+ expect(radio).toHaveAttribute('aria-disabled', 'true');
+ });
+ });
+
+ it('required', async () => {
+ const { wrapper } = await createFixtures();
+ const { Field } = createFormControl('some-radio', 'two', {
+ type: 'radio',
+ radioOptions: [
+ { value: 'one', label: 'One' },
+ { value: 'two', label: 'Two' },
+ ],
+ });
+
+ const { getAllByRole } = render(, { wrapper });
+
+ const radios = getAllByRole('radio');
+ radios.forEach(radio => {
+ expect(radio).toHaveAttribute('required');
+ expect(radio).toHaveAttribute('aria-required', 'true');
+ });
+ });
+
+ it('with error', async () => {
+ const { wrapper } = await createFixtures();
+ const { Field } = createFormControl('some-radio', 'two', {
+ type: 'radio',
+ radioOptions: [
+ { value: 'one', label: 'One' },
+ { value: 'two', label: 'Two' },
+ ],
+ });
+
+ const { getAllByRole, getByRole, getByText } = render(, { wrapper });
+
+ await act(() => userEvent.click(getByRole('button', { name: /set error/i })));
+
+ await waitFor(() => {
+ const radios = getAllByRole('radio');
+ radios.forEach(radio => {
+ expect(radio).toHaveAttribute('aria-invalid', 'true');
+ expect(radio).toHaveAttribute('aria-describedby', 'error-some-radio');
+ });
+ expect(getByText('some error')).toBeInTheDocument();
+ });
+ });
+
+ it('with info', async () => {
+ const { wrapper } = await createFixtures();
+ const { Field } = createFormControl('some-radio', '', {
+ type: 'radio',
+ radioOptions: [
+ { value: 'one', label: 'One' },
+ { value: 'two', label: 'Two' },
+ ],
+ infoText: 'some info',
+ });
+
+ const { getByLabelText, getByText } = render(, { wrapper });
+
+ await act(() => fireEvent.focus(getByLabelText('One')));
+ await waitFor(() => {
+ expect(getByText('some info')).toBeInTheDocument();
+ });
+ });
+});