From 4c3a63fc199ac2bf09eb684449b3f296c13169cf Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 2 Nov 2026 19:38:40 -0700 Subject: [PATCH 1/7] fix(mosaic): send invitations with a valid email still in the input Send now counts a valid address typed into the email field but not yet turned into a tag, in both the invite members dialog and the create organization invite step. An invalid typed address keeps Send unavailable. --- .changeset/mosaic-invite-typed-address.md | 2 + .../src/components/tag-input/tag-input.tsx | 3 +- .../create-organization.feature.test.tsx | 16 +++++ .../create-organization.controller.ts | 7 +- ...ation-profile-invitations.feature.test.tsx | 71 +++++++++++++++++++ ...ation-profile-invite-members.controller.ts | 7 +- ...nization-profile-invite-members.dialog.tsx | 7 +- ...nization-profile-invite-members.fields.tsx | 17 ++++- .../primitives/tag-input/tag-input-input.tsx | 17 +++-- .../primitives/tag-input/tag-input.test.tsx | 19 +++++ 10 files changed, 154 insertions(+), 12 deletions(-) create mode 100644 .changeset/mosaic-invite-typed-address.md diff --git a/.changeset/mosaic-invite-typed-address.md b/.changeset/mosaic-invite-typed-address.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-invite-typed-address.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/tag-input/tag-input.tsx b/packages/mosaic/src/components/tag-input/tag-input.tsx index d11d8cfefc6..df63e9b355a 100644 --- a/packages/mosaic/src/components/tag-input/tag-input.tsx +++ b/packages/mosaic/src/components/tag-input/tag-input.tsx @@ -27,7 +27,8 @@ type TagInputRootProps = Pick< export interface TagInputProps extends Omit, keyof TagInputRootProps | 'render' | 'children' | 'type'>, - TagInputRootProps { + TagInputRootProps, + Pick { removeLabel?: (value: string) => string; } diff --git a/packages/mosaic/src/features/create-organization/__tests__/create-organization.feature.test.tsx b/packages/mosaic/src/features/create-organization/__tests__/create-organization.feature.test.tsx index dc3011c6eb3..7bc08ed9c53 100644 --- a/packages/mosaic/src/features/create-organization/__tests__/create-organization.feature.test.tsx +++ b/packages/mosaic/src/features/create-organization/__tests__/create-organization.feature.test.tsx @@ -302,6 +302,22 @@ describe('CreateOrganization', () => { await waitFor(() => expect(navigate).toHaveBeenCalledWith('/dashboard')); }); + it('sends a valid address still in the input', async () => { + const { fapi } = await renderCreateOrganization(); + const user = userEvent.setup(); + + await create(user, 'Acme'); + await user.click(await screen.findByRole('combobox', { name: /Role/ })); + await user.click(await screen.findByRole('option', { name: 'Member' })); + await user.type(screen.getByRole('textbox', { name: 'Email' }), 'grace@example.com'); + await user.click(screen.getByRole('button', { name: 'Send invitations' })); + + expect(await screen.findByRole('heading', { name: 'Invitations sent' })).toBeVisible(); + expect(fapi.organizationInvitations.map(item => [item.email_address, item.role])).toEqual([ + ['grace@example.com', 'org:member'], + ]); + }); + it('starts on the instance default role', async () => { await renderCreateOrganization( signedIn({ diff --git a/packages/mosaic/src/features/create-organization/create-organization.controller.ts b/packages/mosaic/src/features/create-organization/create-organization.controller.ts index 93f9ed0030f..927bfc1185e 100644 --- a/packages/mosaic/src/features/create-organization/create-organization.controller.ts +++ b/packages/mosaic/src/features/create-organization/create-organization.controller.ts @@ -84,6 +84,7 @@ export function useCreateOrganizationController( interface InviteValues { emailAddresses: string[]; + emailDraft: string; role: string | null; } @@ -98,6 +99,8 @@ export interface CreateOrganizationInviteControllerOptions { export interface CreateOrganizationInviteController { emailAddresses: string[]; onEmailAddressesChange: (emailAddresses: string[]) => void; + emailDraft: string; + onEmailDraftChange: (emailDraft: string) => void; rejectedEmailAddresses: string[]; roles: SelectItem[]; role: string | null; @@ -121,7 +124,7 @@ export function useCreateOrganizationInviteController({ const initialRole = roles ? resolveDefaultRole(roles, defaultRole) : null; const form = useForm({ - initialValues: { emailAddresses: [], role: null }, + initialValues: { emailAddresses: [], emailDraft: '', role: null }, errorFallback: m.invite.error, onSubmit: async ({ emailAddresses, role }) => { const selected = role ?? initialRole; @@ -141,6 +144,8 @@ export function useCreateOrganizationInviteController({ return { emailAddresses: form.values.emailAddresses, onEmailAddressesChange: emailAddresses => form.setValue('emailAddresses', emailAddresses), + emailDraft: form.values.emailDraft, + onEmailDraftChange: emailDraft => form.setValue('emailDraft', emailDraft), rejectedEmailAddresses, roles: (roles?.roles ?? []).map(role => ({ value: role.key, label: roleNames[role.key] ?? role.name })), role: form.values.role ?? initialRole, diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/__tests__/organization-profile-invitations.feature.test.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/__tests__/organization-profile-invitations.feature.test.tsx index 95ccd177c5f..6ac02f569f0 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/__tests__/organization-profile-invitations.feature.test.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/__tests__/organization-profile-invitations.feature.test.tsx @@ -510,6 +510,77 @@ describe('inviting members', () => { expect(dialog.getByRole('button', { name: 'Send invites' })).not.toHaveAttribute('aria-disabled', 'true'); }); + it('sends a valid address still in the input with the selected role', async () => { + const fapi = serveInvite({ defaultRole: 'org:member' }); + await renderWithClerk(); + const { user, dialog } = await openInviteDialog(); + await user.click(dialog.getByRole('combobox', { name: /Role/ })); + await user.click(await screen.findByRole('option', { name: 'Admin' })); + await user.type(dialog.getByRole('textbox', { name: 'Email' }), 'grace@example.com'); + const send = dialog.getByRole('button', { name: 'Send invites' }); + expect(send).not.toHaveAttribute('aria-disabled', 'true'); + + const bulk = holdRequests('post', '/v1/organizations/:organizationId/invitations/bulk'); + await user.click(send); + await waitFor(() => expect(bulk.requests).toHaveLength(1)); + const body = new URLSearchParams(await bulk.requests[0]?.text()); + expect(body.getAll('email_address')).toEqual(['grace@example.com']); + expect(body.get('role')).toBe('org:admin'); + bulk.release(); + await waitFor(() => expect(screen.getByRole('heading', { name: 'Invitation sent' })).toBeVisible()); + expect(fapi.organizationInvitations.filter(item => item.status === 'pending')).toHaveLength(2); + }); + + it.each([ + ['on its own', []], + ['beside a valid address', ['linus@example.com']], + ])('keeps Send unavailable for an invalid address still in the input %s', async (_, tags) => { + serveInvite({ defaultRole: 'org:member' }); + await renderWithClerk(); + const { user, dialog } = await openInviteDialog(); + if (tags.length > 0) { + await addEmails(dialog, ...tags); + } + const bulk = holdRequests('post', '/v1/organizations/:organizationId/invitations/bulk'); + await user.type(dialog.getByRole('textbox', { name: 'Email' }), 'grace@example'); + const send = dialog.getByRole('button', { name: 'Send invites' }); + expect(send).toHaveAttribute('aria-disabled', 'true'); + await user.click(send); + expect(bulk.requests).toHaveLength(0); + bulk.release(); + }); + + it.each([ + ['a new address', 'grace@example.com', ['linus@example.com', 'grace@example.com']], + ['an address already added', 'linus@example.com', ['linus@example.com']], + ])('sends added addresses and %s still in the input once each', async (_, draft, expected) => { + serveInvite({ defaultRole: 'org:member' }); + await renderWithClerk(); + const { user, dialog } = await openInviteDialog(); + await addEmails(dialog, 'linus@example.com'); + await user.type(dialog.getByRole('textbox', { name: 'Email' }), draft); + const bulk = holdRequests('post', '/v1/organizations/:organizationId/invitations/bulk'); + await user.click(dialog.getByRole('button', { name: 'Send invites' })); + await waitFor(() => expect(bulk.requests).toHaveLength(1)); + const body = new URLSearchParams(await bulk.requests[0]?.text()); + expect(body.getAll('email_address')).toEqual(expected); + bulk.release(); + }); + + it('does not keep a typed address available after closing and reopening', async () => { + serveInvite({ defaultRole: 'org:member' }); + await renderWithClerk(); + const { user, dialog } = await openInviteDialog(); + await user.type(dialog.getByRole('textbox', { name: 'Email' }), 'grace@example.com'); + await user.keyboard('{Escape}'); + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Invite members' })).toBeNull()); + + await user.click(screen.getByRole('button', { name: 'Invite' })); + const reopened = within(await screen.findByRole('dialog', { name: 'Invite members' })); + expect(reopened.getByRole('textbox', { name: 'Email' })).toHaveValue(''); + expect(reopened.getByRole('button', { name: 'Send invites' })).toHaveAttribute('aria-disabled', 'true'); + }); + it('ignores a default role that is not in the role list', async () => { serveInvite({ defaultRole: 'org:missing' }); await renderWithClerk(); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller.ts b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller.ts index 0b3a5d0663e..36db1c6b968 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller.ts @@ -10,6 +10,7 @@ import type { InviteMembersInput } from './organization-profile-invite-members.m interface InviteMembersValues { emailAddresses: string[]; + emailDraft: string; role: string | null; } @@ -30,7 +31,7 @@ export function useInviteMembersController({ roles, defaultRole, invite }: Invit const initialRole = roles ? resolveDefaultRole(roles, defaultRole) : null; const form = useForm({ - initialValues: { emailAddresses: [], role: initialRole }, + initialValues: { emailAddresses: [], emailDraft: '', role: initialRole }, errorFallback: m.error, onSubmit: async ({ emailAddresses, role }) => { if (!invite || !roles || role === null) { @@ -56,7 +57,7 @@ export function useInviteMembersController({ roles, defaultRole, invite }: Invit if (form.isSubmitting) { return; } - form.reset({ emailAddresses: [], role: initialRole }); + form.reset({ emailAddresses: [], emailDraft: '', role: initialRole }); setRejectedEmailAddresses([]); setOpen(next); }; @@ -76,6 +77,8 @@ export function useInviteMembersController({ roles, defaultRole, invite }: Invit finalFocus: trigger, emailAddresses: form.values.emailAddresses, onEmailAddressesChange: emailAddresses => form.setValue('emailAddresses', emailAddresses), + emailDraft: form.values.emailDraft, + onEmailDraftChange: emailDraft => form.setValue('emailDraft', emailDraft), rejectedEmailAddresses, roles: roles.roles.map(role => ({ value: role.key, label: roleNames[role.key] ?? role.name })), role: form.values.role, diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.dialog.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.dialog.tsx index 0736d17d40b..55ede83c7a1 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.dialog.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.dialog.tsx @@ -15,6 +15,8 @@ export interface OrganizationProfileInviteMembersDialogProps { finalFocus?: DialogFocusTarget; emailAddresses: string[]; onEmailAddressesChange: (emailAddresses: string[]) => void; + emailDraft: string; + onEmailDraftChange: (emailDraft: string) => void; rejectedEmailAddresses?: string[]; roles: SelectItem[]; role: string | null; @@ -31,6 +33,8 @@ export function OrganizationProfileInviteMembersDialog({ finalFocus, emailAddresses, onEmailAddressesChange, + emailDraft, + onEmailDraftChange, rejectedEmailAddresses = [], roles, role, @@ -43,7 +47,7 @@ export function OrganizationProfileInviteMembersDialog({ const m = useMessages('organizationProfile').members.invite; const formId = useId(); const emailInput = useRef(null); - const canSubmit = canSendInvitations({ emailAddresses, rejectedEmailAddresses, role }); + const canSubmit = canSendInvitations({ emailAddresses, emailDraft, rejectedEmailAddresses, role }); return ( /^\S+@\S+\.\S+$/.test(value); interface InviteMembersValidity { emailAddresses: string[]; + emailDraft?: string; rejectedEmailAddresses?: string[]; role: string | null; } @@ -27,9 +28,18 @@ export function hasInvalidEmailAddresses({ return !emailAddresses.every(value => isValidEmail(value, rejectedEmailAddresses)); } -export function canSendInvitations({ emailAddresses, rejectedEmailAddresses = [], role }: InviteMembersValidity) { +export function canSendInvitations({ + emailAddresses, + emailDraft = '', + rejectedEmailAddresses = [], + role, +}: InviteMembersValidity) { + const draft = emailDraft.trim(); + const pending = draft ? [...emailAddresses, draft] : emailAddresses; return ( - emailAddresses.length > 0 && !hasInvalidEmailAddresses({ emailAddresses, rejectedEmailAddresses }) && role !== null + pending.length > 0 && + !hasInvalidEmailAddresses({ emailAddresses: pending, rejectedEmailAddresses }) && + role !== null ); } @@ -37,6 +47,7 @@ export interface InviteMembersFieldsProps { emailInputRef?: Ref; emailAddresses: string[]; onEmailAddressesChange: (emailAddresses: string[]) => void; + onEmailDraftChange: (emailDraft: string) => void; rejectedEmailAddresses?: string[]; roles: SelectItem[]; role: string | null; @@ -49,6 +60,7 @@ export function InviteMembersFields({ emailInputRef, emailAddresses, onEmailAddressesChange, + onEmailDraftChange, rejectedEmailAddresses = [], roles, role, @@ -77,6 +89,7 @@ export function InviteMembersFields({ delimiters={[',', ' ']} value={emailAddresses} onValueChange={onEmailAddressesChange} + onInputValueChange={onEmailDraftChange} validate={value => isValidEmail(value, rejectedEmailAddresses)} removeLabel={email => fill(m.removeEmail, { email })} /> diff --git a/packages/mosaic/src/primitives/tag-input/tag-input-input.tsx b/packages/mosaic/src/primitives/tag-input/tag-input-input.tsx index aa9196c7c2d..1e37bbda17d 100644 --- a/packages/mosaic/src/primitives/tag-input/tag-input-input.tsx +++ b/packages/mosaic/src/primitives/tag-input/tag-input-input.tsx @@ -7,13 +7,20 @@ import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '. import { useTagInputContext } from './tag-input-context'; import { splitTags } from './tag-input-utils'; -export type TagInputInputProps = Omit, 'value' | 'defaultValue'>; +export type TagInputInputProps = Omit, 'value' | 'defaultValue'> & { + onInputValueChange?: (text: string) => void; +}; export const TagInputInput = React.forwardRef( function TagInputInput(props, forwardedRef) { - const { render, ...otherProps } = props; + const { render, onInputValueChange, ...otherProps } = props; const { value, delimiters, disabled, focusableWhenDisabled, add, focusTag, registerInput } = useTagInputContext(); const [text, setText] = useState(''); + + const updateText = (next: string) => { + setText(next); + onInputValueChange?.(next); + }; const { previousKey } = readingDirectionKeys(useDirection()); const commit = () => { @@ -21,7 +28,7 @@ export const TagInputInput = React.forwardRef) => { if (disabled) { @@ -46,7 +53,7 @@ export const TagInputInput = React.forwardRef) => { diff --git a/packages/mosaic/src/primitives/tag-input/tag-input.test.tsx b/packages/mosaic/src/primitives/tag-input/tag-input.test.tsx index 720313b7a68..4835c384eb1 100644 --- a/packages/mosaic/src/primitives/tag-input/tag-input.test.tsx +++ b/packages/mosaic/src/primitives/tag-input/tag-input.test.tsx @@ -131,6 +131,25 @@ describe('TagInput', () => { expect(tagValues()).toEqual(['a@clerk.dev']); }); + it('reports the text not yet added as a tag', async () => { + const user = userEvent.setup(); + const onInputValueChange = vi.fn(); + render( + + + , + ); + + await user.type(input(), 'a@clerk.dev,b@c'); + expect(onInputValueChange).toHaveBeenLastCalledWith('b@c'); + + await user.tab(); + expect(onInputValueChange).toHaveBeenLastCalledWith(''); + }); + it('ignores empty text and duplicates', async () => { const user = userEvent.setup(); render(); From edfe8709453af3ca300dc76fd0debc414c37efe2 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 2 Nov 2026 19:43:20 -0700 Subject: [PATCH 2/7] fix(mosaic): pass the typed address through the invite dialog fixtures --- .../organization-profile-invite-members.dialog.test.tsx | 3 +++ .../stories/fixtures/organization-profile-invite-members.ts | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/__tests__/organization-profile-invite-members.dialog.test.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/__tests__/organization-profile-invite-members.dialog.test.tsx index 4262b473274..ef5026567f7 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/__tests__/organization-profile-invite-members.dialog.test.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/__tests__/organization-profile-invite-members.dialog.test.tsx @@ -13,6 +13,7 @@ type HarnessProps = Partial('member'); return ( (null); const [open, setOpen] = useState(false); const [emailAddresses, setEmailAddresses] = useState([]); + const [emailDraft, setEmailDraft] = useState(''); const [rejectedEmailAddresses, setRejectedEmailAddresses] = useState([]); const [role, setRole] = useState('member'); const [isPending, setIsPending] = useState(false); @@ -34,6 +35,7 @@ export function useInviteMembersFixture(): { onInvite: event => { trigger.current = event.currentTarget; setEmailAddresses([]); + setEmailDraft(''); setRejectedEmailAddresses([]); setRole('member'); setError(null); @@ -45,6 +47,8 @@ export function useInviteMembersFixture(): { finalFocus: trigger, emailAddresses, onEmailAddressesChange: setEmailAddresses, + emailDraft, + onEmailDraftChange: setEmailDraft, rejectedEmailAddresses, roles, role, From 9a917c7585e55c5a5b62f73c98653432021ea7be Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 2 Nov 2026 22:12:06 -0700 Subject: [PATCH 3/7] refactor(mosaic): decide invite send availability in the controllers --- .../create-organization.controller.ts | 6 ++- .../create-organization.view.tsx | 10 ++--- ...ion-profile-invite-members.dialog.test.tsx | 3 +- ...ation-profile-invite-members.controller.ts | 4 +- ...nization-profile-invite-members.dialog.tsx | 7 ++-- ...nization-profile-invite-members.fields.tsx | 38 +------------------ ...ganization-profile-invite-members.utils.ts | 27 +++++++++++++ .../organization-profile-invite-members.ts | 3 +- 8 files changed, 46 insertions(+), 52 deletions(-) create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.utils.ts diff --git a/packages/mosaic/src/features/create-organization/create-organization.controller.ts b/packages/mosaic/src/features/create-organization/create-organization.controller.ts index 927bfc1185e..9765b840c81 100644 --- a/packages/mosaic/src/features/create-organization/create-organization.controller.ts +++ b/packages/mosaic/src/features/create-organization/create-organization.controller.ts @@ -6,6 +6,7 @@ import type { SelectItem } from '../../components/select'; import { useMessages } from '../../localization'; import type { MembersRoles } from '../organization-profile/organization-profile-members-panel/members-table-tab.types'; import { resolveDefaultRole } from '../organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller'; +import { canSendInvitations } from '../organization-profile/organization-profile-members-panel/organization-profile-invite-members.utils'; import type { CreateOrganizationModel } from './create-organization.model'; import type { CreatedOrganization, @@ -99,13 +100,13 @@ export interface CreateOrganizationInviteControllerOptions { export interface CreateOrganizationInviteController { emailAddresses: string[]; onEmailAddressesChange: (emailAddresses: string[]) => void; - emailDraft: string; onEmailDraftChange: (emailDraft: string) => void; rejectedEmailAddresses: string[]; roles: SelectItem[]; role: string | null; onRoleChange: (role: string | null) => void; isRoleDisabled: boolean; + canSubmit: boolean; isPending: boolean; error: string | undefined; onSubmit: () => void; @@ -126,6 +127,7 @@ export function useCreateOrganizationInviteController({ const form = useForm({ initialValues: { emailAddresses: [], emailDraft: '', role: null }, errorFallback: m.invite.error, + canSubmit: values => canSendInvitations({ ...values, role: values.role ?? initialRole, rejectedEmailAddresses }), onSubmit: async ({ emailAddresses, role }) => { const selected = role ?? initialRole; if (!organizationId || selected === null) { @@ -144,13 +146,13 @@ export function useCreateOrganizationInviteController({ return { emailAddresses: form.values.emailAddresses, onEmailAddressesChange: emailAddresses => form.setValue('emailAddresses', emailAddresses), - emailDraft: form.values.emailDraft, onEmailDraftChange: emailDraft => form.setValue('emailDraft', emailDraft), rejectedEmailAddresses, roles: (roles?.roles ?? []).map(role => ({ value: role.key, label: roleNames[role.key] ?? role.name })), role: form.values.role ?? initialRole, onRoleChange: role => form.setValue('role', role), isRoleDisabled: !roles || roles.hasRoleSetMigration, + canSubmit: form.canSubmit || form.isSubmitting, isPending: form.isSubmitting, error: form.error, onSubmit: form.submit, diff --git a/packages/mosaic/src/features/create-organization/create-organization.view.tsx b/packages/mosaic/src/features/create-organization/create-organization.view.tsx index 6a3df8d5c03..c0830ca9511 100644 --- a/packages/mosaic/src/features/create-organization/create-organization.view.tsx +++ b/packages/mosaic/src/features/create-organization/create-organization.view.tsx @@ -13,10 +13,7 @@ import { useErrorText, useMessages } from '../../localization'; import { FileUpload } from '../../primitives/file-upload'; import { Form } from '../../primitives/form'; import { AVATAR_REJECTION_ERRORS } from '../../utils/avatar-upload'; -import { - canSendInvitations, - InviteMembersFields, -} from '../organization-profile/organization-profile-members-panel/organization-profile-invite-members.fields'; +import { InviteMembersFields } from '../organization-profile/organization-profile-members-panel/organization-profile-invite-members.fields'; import type { CreateOrganizationInviteController } from './create-organization.controller'; import type { CreateOrganizationStep, CreateOrganizationValues } from './create-organization.types'; @@ -212,7 +209,6 @@ function LogoField({ function InviteStep({ invite, onFinish }: CreateOrganizationStepsProps) { const m = useMessages('createOrganization'); const formId = useId(); - const canSubmit = canSendInvitations(invite); const emailInput = useFlowAutoFocus(); return ( @@ -232,7 +228,7 @@ function InviteStep({ invite, onFinish }: CreateOrganizationStepsProps) { id={formId} onSubmit={event => { event.preventDefault(); - if (canSubmit && !invite.isPending) { + if (invite.canSubmit && !invite.isPending) { invite.onSubmit(); } }} @@ -256,7 +252,7 @@ function InviteStep({ invite, onFinish }: CreateOrganizationStepsProps) { {m.invite.submit} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/__tests__/organization-profile-invite-members.dialog.test.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/__tests__/organization-profile-invite-members.dialog.test.tsx index ef5026567f7..0ac4f4aae43 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/__tests__/organization-profile-invite-members.dialog.test.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/__tests__/organization-profile-invite-members.dialog.test.tsx @@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../../../mosaic-provider'; import type { OrganizationProfileInviteMembersDialogProps } from '../organization-profile-invite-members.dialog'; import { OrganizationProfileInviteMembersDialog } from '../organization-profile-invite-members.dialog'; +import { canSendInvitations } from '../organization-profile-invite-members.utils'; type HarnessProps = Partial> & { initialEmailAddresses?: string[]; @@ -21,7 +22,6 @@ function Harness({ initialEmailAddresses = [], ...overrides }: HarnessProps) { onOpenChange={vi.fn()} emailAddresses={emailAddresses} onEmailAddressesChange={setEmailAddresses} - emailDraft={emailDraft} onEmailDraftChange={setEmailDraft} roles={[ { value: 'member', label: 'Member', description: 'Non-privileged permissions.' }, @@ -29,6 +29,7 @@ function Harness({ initialEmailAddresses = [], ...overrides }: HarnessProps) { ]} role={role} onRoleChange={setRole} + canSubmit={canSendInvitations({ emailAddresses, emailDraft, rejectedEmailAddresses: [], role })} isPending={false} error={null} onSubmit={vi.fn()} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller.ts b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller.ts index 36db1c6b968..05bd3f118d9 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller.ts @@ -7,6 +7,7 @@ import { plural, useLocale, useMessages } from '../../../localization'; import type { MembersRoles } from './members-table-tab.types'; import type { OrganizationProfileInviteMembersDialogProps } from './organization-profile-invite-members.dialog'; import type { InviteMembersInput } from './organization-profile-invite-members.model'; +import { canSendInvitations } from './organization-profile-invite-members.utils'; interface InviteMembersValues { emailAddresses: string[]; @@ -33,6 +34,7 @@ export function useInviteMembersController({ roles, defaultRole, invite }: Invit const form = useForm({ initialValues: { emailAddresses: [], emailDraft: '', role: initialRole }, errorFallback: m.error, + canSubmit: values => canSendInvitations({ ...values, rejectedEmailAddresses }), onSubmit: async ({ emailAddresses, role }) => { if (!invite || !roles || role === null) { return false; @@ -77,13 +79,13 @@ export function useInviteMembersController({ roles, defaultRole, invite }: Invit finalFocus: trigger, emailAddresses: form.values.emailAddresses, onEmailAddressesChange: emailAddresses => form.setValue('emailAddresses', emailAddresses), - emailDraft: form.values.emailDraft, onEmailDraftChange: emailDraft => form.setValue('emailDraft', emailDraft), rejectedEmailAddresses, roles: roles.roles.map(role => ({ value: role.key, label: roleNames[role.key] ?? role.name })), role: form.values.role, onRoleChange: role => form.setValue('role', role), isRoleDisabled: roles.hasRoleSetMigration, + canSubmit: form.canSubmit || form.isSubmitting, isPending: form.isSubmitting, error: form.error ?? null, onSubmit: form.submit, diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.dialog.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.dialog.tsx index 55ede83c7a1..34aceeb8684 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.dialog.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.dialog.tsx @@ -7,7 +7,7 @@ import { Dialog } from '../../../components/dialog'; import type { SelectItem } from '../../../components/select'; import { useMessages } from '../../../localization'; import { Form } from '../../../primitives/form'; -import { canSendInvitations, InviteMembersFields } from './organization-profile-invite-members.fields'; +import { InviteMembersFields } from './organization-profile-invite-members.fields'; export interface OrganizationProfileInviteMembersDialogProps { open: boolean; @@ -15,13 +15,13 @@ export interface OrganizationProfileInviteMembersDialogProps { finalFocus?: DialogFocusTarget; emailAddresses: string[]; onEmailAddressesChange: (emailAddresses: string[]) => void; - emailDraft: string; onEmailDraftChange: (emailDraft: string) => void; rejectedEmailAddresses?: string[]; roles: SelectItem[]; role: string | null; onRoleChange: (role: string | null) => void; isRoleDisabled?: boolean; + canSubmit: boolean; isPending: boolean; error: string | null; onSubmit: () => void | Promise; @@ -33,13 +33,13 @@ export function OrganizationProfileInviteMembersDialog({ finalFocus, emailAddresses, onEmailAddressesChange, - emailDraft, onEmailDraftChange, rejectedEmailAddresses = [], roles, role, onRoleChange, isRoleDisabled = false, + canSubmit, isPending, error, onSubmit, @@ -47,7 +47,6 @@ export function OrganizationProfileInviteMembersDialog({ const m = useMessages('organizationProfile').members.invite; const formId = useId(); const emailInput = useRef(null); - const canSubmit = canSendInvitations({ emailAddresses, emailDraft, rejectedEmailAddresses, role }); return ( /^\S+@\S+\.\S+$/.test(value); - -interface InviteMembersValidity { - emailAddresses: string[]; - emailDraft?: string; - rejectedEmailAddresses?: string[]; - role: string | null; -} - -function isValidEmail(value: string, rejectedEmailAddresses: string[]) { - return isEmail(value) && !rejectedEmailAddresses.includes(value); -} - -export function hasInvalidEmailAddresses({ - emailAddresses, - rejectedEmailAddresses = [], -}: Omit) { - return !emailAddresses.every(value => isValidEmail(value, rejectedEmailAddresses)); -} - -export function canSendInvitations({ - emailAddresses, - emailDraft = '', - rejectedEmailAddresses = [], - role, -}: InviteMembersValidity) { - const draft = emailDraft.trim(); - const pending = draft ? [...emailAddresses, draft] : emailAddresses; - return ( - pending.length > 0 && - !hasInvalidEmailAddresses({ emailAddresses: pending, rejectedEmailAddresses }) && - role !== null - ); -} +import { hasInvalidEmailAddresses, isValidEmail } from './organization-profile-invite-members.utils'; export interface InviteMembersFieldsProps { emailInputRef?: Ref; @@ -69,7 +35,7 @@ export function InviteMembersFields({ isPending, }: InviteMembersFieldsProps) { const m = useMessages('organizationProfile').members.invite; - const hasInvalid = hasInvalidEmailAddresses({ emailAddresses, rejectedEmailAddresses }); + const hasInvalid = hasInvalidEmailAddresses(emailAddresses, rejectedEmailAddresses); return ( <> diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.utils.ts b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.utils.ts new file mode 100644 index 00000000000..b42e665007d --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.utils.ts @@ -0,0 +1,27 @@ +const isEmail = (value: string) => /^\S+@\S+\.\S+$/.test(value); + +interface InviteMembersValidity { + emailAddresses: string[]; + emailDraft: string; + rejectedEmailAddresses: string[]; + role: string | null; +} + +export function isValidEmail(value: string, rejectedEmailAddresses: string[]) { + return isEmail(value) && !rejectedEmailAddresses.includes(value); +} + +export function hasInvalidEmailAddresses(emailAddresses: string[], rejectedEmailAddresses: string[]) { + return !emailAddresses.every(value => isValidEmail(value, rejectedEmailAddresses)); +} + +export function canSendInvitations({ + emailAddresses, + emailDraft, + rejectedEmailAddresses, + role, +}: InviteMembersValidity) { + const draft = emailDraft.trim(); + const pending = draft ? [...emailAddresses, draft] : emailAddresses; + return pending.length > 0 && !hasInvalidEmailAddresses(pending, rejectedEmailAddresses) && role !== null; +} diff --git a/packages/swingset/src/stories/fixtures/organization-profile-invite-members.ts b/packages/swingset/src/stories/fixtures/organization-profile-invite-members.ts index f48c8596e61..0d78106ac1a 100644 --- a/packages/swingset/src/stories/fixtures/organization-profile-invite-members.ts +++ b/packages/swingset/src/stories/fixtures/organization-profile-invite-members.ts @@ -1,5 +1,6 @@ import { useToastManager } from '@clerk/mosaic/components/toast'; import type { OrganizationProfileInviteMembersDialogProps } from '@clerk/mosaic/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.dialog'; +import { canSendInvitations } from '@clerk/mosaic/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.utils'; import { plural, useLocale, useMessages } from '@clerk/mosaic/localization'; import type { MouseEvent } from 'react'; import { useRef, useState } from 'react'; @@ -47,12 +48,12 @@ export function useInviteMembersFixture(): { finalFocus: trigger, emailAddresses, onEmailAddressesChange: setEmailAddresses, - emailDraft, onEmailDraftChange: setEmailDraft, rejectedEmailAddresses, roles, role, onRoleChange: setRole, + canSubmit: canSendInvitations({ emailAddresses, emailDraft, rejectedEmailAddresses, role }), isPending, error, onSubmit: async () => { From 98123478e8f49dd62a2437b7fd27f729cc4a2b7f Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 2 Nov 2026 23:25:11 -0700 Subject: [PATCH 4/7] test(mosaic): wait for invite roles before opening the role select --- .../__tests__/create-organization.feature.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/mosaic/src/features/create-organization/__tests__/create-organization.feature.test.tsx b/packages/mosaic/src/features/create-organization/__tests__/create-organization.feature.test.tsx index 7bc08ed9c53..adb8b52aebf 100644 --- a/packages/mosaic/src/features/create-organization/__tests__/create-organization.feature.test.tsx +++ b/packages/mosaic/src/features/create-organization/__tests__/create-organization.feature.test.tsx @@ -307,7 +307,9 @@ describe('CreateOrganization', () => { const user = userEvent.setup(); await create(user, 'Acme'); - await user.click(await screen.findByRole('combobox', { name: /Role/ })); + const role = await screen.findByRole('combobox', { name: /Role/ }); + await waitFor(() => expect(role).toBeEnabled()); + await user.click(role); await user.click(await screen.findByRole('option', { name: 'Member' })); await user.type(screen.getByRole('textbox', { name: 'Email' }), 'grace@example.com'); await user.click(screen.getByRole('button', { name: 'Send invitations' })); From 1378eaa0f01bcaecb20c89ff6a7fe6896d10be9e Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 2 Nov 2026 23:50:07 -0700 Subject: [PATCH 5/7] refactor(mosaic): control the tag input draft and send it with the tags --- .../src/components/tag-input/tag-input.tsx | 21 ++++++++++-- .../create-organization.controller.ts | 17 +++++++--- ...ion-profile-invite-members.dialog.test.tsx | 1 + ...ation-profile-invite-members.controller.ts | 17 +++++++--- ...nization-profile-invite-members.dialog.tsx | 3 ++ ...nization-profile-invite-members.fields.tsx | 3 ++ ...ganization-profile-invite-members.utils.ts | 12 ++++--- .../primitives/tag-input/tag-input-context.ts | 2 ++ .../primitives/tag-input/tag-input-input.tsx | 32 +++++++++-------- .../primitives/tag-input/tag-input-root.tsx | 11 ++++++ .../primitives/tag-input/tag-input.test.tsx | 34 ++++++++++++++----- .../organization-profile-invite-members.ts | 11 ++++-- .../src/stories/tag-input.component.mdx | 3 ++ packages/swingset/src/stories/tag-input.mdx | 21 +++++++----- 14 files changed, 136 insertions(+), 52 deletions(-) diff --git a/packages/mosaic/src/components/tag-input/tag-input.tsx b/packages/mosaic/src/components/tag-input/tag-input.tsx index df63e9b355a..78f1f8a2aee 100644 --- a/packages/mosaic/src/components/tag-input/tag-input.tsx +++ b/packages/mosaic/src/components/tag-input/tag-input.tsx @@ -21,14 +21,23 @@ import { styles } from './tag-input.styles'; type TagInputRootProps = Pick< PrimitiveTagInputProps, - 'value' | 'defaultValue' | 'onValueChange' | 'validate' | 'delimiters' | 'name' | 'form' | 'disabled' + | 'value' + | 'defaultValue' + | 'onValueChange' + | 'inputValue' + | 'defaultInputValue' + | 'onInputValueChange' + | 'validate' + | 'delimiters' + | 'name' + | 'form' + | 'disabled' >; export interface TagInputProps extends Omit, keyof TagInputRootProps | 'render' | 'children' | 'type'>, - TagInputRootProps, - Pick { + TagInputRootProps { removeLabel?: (value: string) => string; } @@ -93,6 +102,9 @@ export const TagInput = React.forwardRef(functi value, defaultValue, onValueChange, + inputValue, + defaultInputValue, + onInputValueChange, validate, delimiters, name, @@ -131,6 +143,9 @@ export const TagInput = React.forwardRef(functi value={value} defaultValue={defaultValue} onValueChange={onValueChange} + inputValue={inputValue} + defaultInputValue={defaultInputValue} + onInputValueChange={onInputValueChange} validate={validate} delimiters={delimiters} name={name} diff --git a/packages/mosaic/src/features/create-organization/create-organization.controller.ts b/packages/mosaic/src/features/create-organization/create-organization.controller.ts index 9765b840c81..a8df8db795d 100644 --- a/packages/mosaic/src/features/create-organization/create-organization.controller.ts +++ b/packages/mosaic/src/features/create-organization/create-organization.controller.ts @@ -6,7 +6,10 @@ import type { SelectItem } from '../../components/select'; import { useMessages } from '../../localization'; import type { MembersRoles } from '../organization-profile/organization-profile-members-panel/members-table-tab.types'; import { resolveDefaultRole } from '../organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller'; -import { canSendInvitations } from '../organization-profile/organization-profile-members-panel/organization-profile-invite-members.utils'; +import { + canSendInvitations, + pendingEmailAddresses, +} from '../organization-profile/organization-profile-members-panel/organization-profile-invite-members.utils'; import type { CreateOrganizationModel } from './create-organization.model'; import type { CreatedOrganization, @@ -100,6 +103,7 @@ export interface CreateOrganizationInviteControllerOptions { export interface CreateOrganizationInviteController { emailAddresses: string[]; onEmailAddressesChange: (emailAddresses: string[]) => void; + emailDraft: string; onEmailDraftChange: (emailDraft: string) => void; rejectedEmailAddresses: string[]; roles: SelectItem[]; @@ -128,12 +132,16 @@ export function useCreateOrganizationInviteController({ initialValues: { emailAddresses: [], emailDraft: '', role: null }, errorFallback: m.invite.error, canSubmit: values => canSendInvitations({ ...values, role: values.role ?? initialRole, rejectedEmailAddresses }), - onSubmit: async ({ emailAddresses, role }) => { - const selected = role ?? initialRole; + onSubmit: async values => { + const selected = values.role ?? initialRole; if (!organizationId || selected === null) { return false; } - await onInvite(organizationId, { emailAddresses, role: selected, onRejected: setRejectedEmailAddresses }); + await onInvite(organizationId, { + emailAddresses: pendingEmailAddresses(values), + role: selected, + onRejected: setRejectedEmailAddresses, + }); return true; }, onSuccess: sent => { @@ -146,6 +154,7 @@ export function useCreateOrganizationInviteController({ return { emailAddresses: form.values.emailAddresses, onEmailAddressesChange: emailAddresses => form.setValue('emailAddresses', emailAddresses), + emailDraft: form.values.emailDraft, onEmailDraftChange: emailDraft => form.setValue('emailDraft', emailDraft), rejectedEmailAddresses, roles: (roles?.roles ?? []).map(role => ({ value: role.key, label: roleNames[role.key] ?? role.name })), diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/__tests__/organization-profile-invite-members.dialog.test.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/__tests__/organization-profile-invite-members.dialog.test.tsx index 0ac4f4aae43..7807062f0c5 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/__tests__/organization-profile-invite-members.dialog.test.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/__tests__/organization-profile-invite-members.dialog.test.tsx @@ -22,6 +22,7 @@ function Harness({ initialEmailAddresses = [], ...overrides }: HarnessProps) { onOpenChange={vi.fn()} emailAddresses={emailAddresses} onEmailAddressesChange={setEmailAddresses} + emailDraft={emailDraft} onEmailDraftChange={setEmailDraft} roles={[ { value: 'member', label: 'Member', description: 'Non-privileged permissions.' }, diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller.ts b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller.ts index 05bd3f118d9..168a4b9d050 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller.ts @@ -7,7 +7,7 @@ import { plural, useLocale, useMessages } from '../../../localization'; import type { MembersRoles } from './members-table-tab.types'; import type { OrganizationProfileInviteMembersDialogProps } from './organization-profile-invite-members.dialog'; import type { InviteMembersInput } from './organization-profile-invite-members.model'; -import { canSendInvitations } from './organization-profile-invite-members.utils'; +import { canSendInvitations, pendingEmailAddresses } from './organization-profile-invite-members.utils'; interface InviteMembersValues { emailAddresses: string[]; @@ -35,19 +35,25 @@ export function useInviteMembersController({ roles, defaultRole, invite }: Invit initialValues: { emailAddresses: [], emailDraft: '', role: initialRole }, errorFallback: m.error, canSubmit: values => canSendInvitations({ ...values, rejectedEmailAddresses }), - onSubmit: async ({ emailAddresses, role }) => { + onSubmit: async values => { + const { role } = values; if (!invite || !roles || role === null) { return false; } - await invite({ emailAddresses, role, roles, onRejected: setRejectedEmailAddresses }); + await invite({ + emailAddresses: pendingEmailAddresses(values), + role, + roles, + onRejected: setRejectedEmailAddresses, + }); return true; }, - onSuccess: (invited, { emailAddresses }) => { + onSuccess: (invited, values) => { if (!invited) { return; } setOpen(false); - toast.add({ type: 'success', label: plural(m.sent, emailAddresses.length, locale) }); + toast.add({ type: 'success', label: plural(m.sent, pendingEmailAddresses(values).length, locale) }); }, }); @@ -79,6 +85,7 @@ export function useInviteMembersController({ roles, defaultRole, invite }: Invit finalFocus: trigger, emailAddresses: form.values.emailAddresses, onEmailAddressesChange: emailAddresses => form.setValue('emailAddresses', emailAddresses), + emailDraft: form.values.emailDraft, onEmailDraftChange: emailDraft => form.setValue('emailDraft', emailDraft), rejectedEmailAddresses, roles: roles.roles.map(role => ({ value: role.key, label: roleNames[role.key] ?? role.name })), diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.dialog.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.dialog.tsx index 34aceeb8684..992e64100b2 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.dialog.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.dialog.tsx @@ -15,6 +15,7 @@ export interface OrganizationProfileInviteMembersDialogProps { finalFocus?: DialogFocusTarget; emailAddresses: string[]; onEmailAddressesChange: (emailAddresses: string[]) => void; + emailDraft: string; onEmailDraftChange: (emailDraft: string) => void; rejectedEmailAddresses?: string[]; roles: SelectItem[]; @@ -33,6 +34,7 @@ export function OrganizationProfileInviteMembersDialog({ finalFocus, emailAddresses, onEmailAddressesChange, + emailDraft, onEmailDraftChange, rejectedEmailAddresses = [], roles, @@ -92,6 +94,7 @@ export function OrganizationProfileInviteMembersDialog({ emailInputRef={emailInput} emailAddresses={emailAddresses} onEmailAddressesChange={onEmailAddressesChange} + emailDraft={emailDraft} onEmailDraftChange={onEmailDraftChange} rejectedEmailAddresses={rejectedEmailAddresses} roles={roles} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.fields.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.fields.tsx index 4fdb2aad092..e20a36a359c 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.fields.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.fields.tsx @@ -13,6 +13,7 @@ export interface InviteMembersFieldsProps { emailInputRef?: Ref; emailAddresses: string[]; onEmailAddressesChange: (emailAddresses: string[]) => void; + emailDraft: string; onEmailDraftChange: (emailDraft: string) => void; rejectedEmailAddresses?: string[]; roles: SelectItem[]; @@ -26,6 +27,7 @@ export function InviteMembersFields({ emailInputRef, emailAddresses, onEmailAddressesChange, + emailDraft, onEmailDraftChange, rejectedEmailAddresses = [], roles, @@ -55,6 +57,7 @@ export function InviteMembersFields({ delimiters={[',', ' ']} value={emailAddresses} onValueChange={onEmailAddressesChange} + inputValue={emailDraft} onInputValueChange={onEmailDraftChange} validate={value => isValidEmail(value, rejectedEmailAddresses)} removeLabel={email => fill(m.removeEmail, { email })} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.utils.ts b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.utils.ts index b42e665007d..08dbe889013 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.utils.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.utils.ts @@ -15,13 +15,15 @@ export function hasInvalidEmailAddresses(emailAddresses: string[], rejectedEmail return !emailAddresses.every(value => isValidEmail(value, rejectedEmailAddresses)); } -export function canSendInvitations({ +export function pendingEmailAddresses({ emailAddresses, emailDraft, - rejectedEmailAddresses, - role, -}: InviteMembersValidity) { +}: Pick) { const draft = emailDraft.trim(); - const pending = draft ? [...emailAddresses, draft] : emailAddresses; + return draft && !emailAddresses.includes(draft) ? [...emailAddresses, draft] : emailAddresses; +} + +export function canSendInvitations({ rejectedEmailAddresses, role, ...input }: InviteMembersValidity) { + const pending = pendingEmailAddresses(input); return pending.length > 0 && !hasInvalidEmailAddresses(pending, rejectedEmailAddresses) && role !== null; } diff --git a/packages/mosaic/src/primitives/tag-input/tag-input-context.ts b/packages/mosaic/src/primitives/tag-input/tag-input-context.ts index 1e2deb33ac4..1b84e060368 100644 --- a/packages/mosaic/src/primitives/tag-input/tag-input-context.ts +++ b/packages/mosaic/src/primitives/tag-input/tag-input-context.ts @@ -11,6 +11,8 @@ export type FocusAfterRemove = 'input' | 'previous' | 'next'; export interface TagInputContextValue { value: string[]; tags: TagInputTag[]; + inputValue: string; + setInputValue: (inputValue: string) => void; delimiters: string[]; disabled: boolean; focusableWhenDisabled: boolean; diff --git a/packages/mosaic/src/primitives/tag-input/tag-input-input.tsx b/packages/mosaic/src/primitives/tag-input/tag-input-input.tsx index 1e37bbda17d..4f3545362f7 100644 --- a/packages/mosaic/src/primitives/tag-input/tag-input-input.tsx +++ b/packages/mosaic/src/primitives/tag-input/tag-input-input.tsx @@ -1,26 +1,28 @@ 'use client'; -import React, { useState } from 'react'; +import React from 'react'; import { readingDirectionKeys, useDirection } from '../direction-provider'; import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../utils'; import { useTagInputContext } from './tag-input-context'; import { splitTags } from './tag-input-utils'; -export type TagInputInputProps = Omit, 'value' | 'defaultValue'> & { - onInputValueChange?: (text: string) => void; -}; +export type TagInputInputProps = Omit, 'value' | 'defaultValue'>; export const TagInputInput = React.forwardRef( function TagInputInput(props, forwardedRef) { - const { render, onInputValueChange, ...otherProps } = props; - const { value, delimiters, disabled, focusableWhenDisabled, add, focusTag, registerInput } = useTagInputContext(); - const [text, setText] = useState(''); - - const updateText = (next: string) => { - setText(next); - onInputValueChange?.(next); - }; + const { render, ...otherProps } = props; + const { + value, + inputValue: text, + setInputValue: setText, + delimiters, + disabled, + focusableWhenDisabled, + add, + focusTag, + registerInput, + } = useTagInputContext(); const { previousKey } = readingDirectionKeys(useDirection()); const commit = () => { @@ -28,7 +30,7 @@ export const TagInputInput = React.forwardRef) => { if (disabled) { @@ -53,7 +55,7 @@ export const TagInputInput = React.forwardRef) => { diff --git a/packages/mosaic/src/primitives/tag-input/tag-input-root.tsx b/packages/mosaic/src/primitives/tag-input/tag-input-root.tsx index 3022f146abd..d264c25e653 100644 --- a/packages/mosaic/src/primitives/tag-input/tag-input-root.tsx +++ b/packages/mosaic/src/primitives/tag-input/tag-input-root.tsx @@ -19,6 +19,9 @@ export interface TagInputProps extends Omit, 'defaultValue value?: string[]; defaultValue?: string[]; onValueChange?: (value: string[]) => void; + inputValue?: string; + defaultInputValue?: string; + onInputValueChange?: (inputValue: string) => void; validate?: (value: string) => boolean; delimiters?: string[]; name?: string; @@ -34,6 +37,9 @@ export const TagInputRoot = React.forwardRef(func value: valueProp, defaultValue = EMPTY, onValueChange, + inputValue: inputValueProp, + defaultInputValue = '', + onInputValueChange, validate, delimiters = DEFAULT_DELIMITERS, name, @@ -45,6 +51,7 @@ export const TagInputRoot = React.forwardRef(func } = props; const [value, setValue] = useControllableState(valueProp, defaultValue, onValueChange); + const [inputValue, setInputValue] = useControllableState(inputValueProp, defaultInputValue, onInputValueChange); const valueRef = useRef(value); valueRef.current = value; @@ -137,6 +144,8 @@ export const TagInputRoot = React.forwardRef(func () => ({ value, tags, + inputValue, + setInputValue, delimiters, disabled, focusableWhenDisabled, @@ -151,6 +160,8 @@ export const TagInputRoot = React.forwardRef(func [ value, tags, + inputValue, + setInputValue, delimiters, disabled, focusableWhenDisabled, diff --git a/packages/mosaic/src/primitives/tag-input/tag-input.test.tsx b/packages/mosaic/src/primitives/tag-input/tag-input.test.tsx index 4835c384eb1..7ce22b5cc1f 100644 --- a/packages/mosaic/src/primitives/tag-input/tag-input.test.tsx +++ b/packages/mosaic/src/primitives/tag-input/tag-input.test.tsx @@ -134,14 +134,7 @@ describe('TagInput', () => { it('reports the text not yet added as a tag', async () => { const user = userEvent.setup(); const onInputValueChange = vi.fn(); - render( - - - , - ); + render(); await user.type(input(), 'a@clerk.dev,b@c'); expect(onInputValueChange).toHaveBeenLastCalledWith('b@c'); @@ -150,6 +143,31 @@ describe('TagInput', () => { expect(onInputValueChange).toHaveBeenLastCalledWith(''); }); + it('shows the controlled input value and reports edits without applying them', async () => { + const user = userEvent.setup(); + const onInputValueChange = vi.fn(); + const { rerender } = render( + , + ); + expect(input()).toHaveValue('a@clerk'); + + await user.type(input(), '.'); + expect(onInputValueChange).toHaveBeenLastCalledWith('a@clerk.'); + expect(input()).toHaveValue('a@clerk'); + + rerender( + , + ); + expect(input()).toHaveValue(''); + expect(tagValues()).toEqual([]); + }); + it('ignores empty text and duplicates', async () => { const user = userEvent.setup(); render(); diff --git a/packages/swingset/src/stories/fixtures/organization-profile-invite-members.ts b/packages/swingset/src/stories/fixtures/organization-profile-invite-members.ts index 0d78106ac1a..935faa6331a 100644 --- a/packages/swingset/src/stories/fixtures/organization-profile-invite-members.ts +++ b/packages/swingset/src/stories/fixtures/organization-profile-invite-members.ts @@ -1,6 +1,9 @@ import { useToastManager } from '@clerk/mosaic/components/toast'; import type { OrganizationProfileInviteMembersDialogProps } from '@clerk/mosaic/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.dialog'; -import { canSendInvitations } from '@clerk/mosaic/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.utils'; +import { + canSendInvitations, + pendingEmailAddresses, +} from '@clerk/mosaic/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.utils'; import { plural, useLocale, useMessages } from '@clerk/mosaic/localization'; import type { MouseEvent } from 'react'; import { useRef, useState } from 'react'; @@ -48,6 +51,7 @@ export function useInviteMembersFixture(): { finalFocus: trigger, emailAddresses, onEmailAddressesChange: setEmailAddresses, + emailDraft, onEmailDraftChange: setEmailDraft, rejectedEmailAddresses, roles, @@ -61,14 +65,15 @@ export function useInviteMembersFixture(): { setError(null); await new Promise(resolve => setTimeout(resolve, 800)); setIsPending(false); - const rejected = emailAddresses.filter(email => existingMembers.includes(email)); + const invited = pendingEmailAddresses({ emailAddresses, emailDraft }); + const rejected = invited.filter(email => existingMembers.includes(email)); if (rejected.length > 0) { setRejectedEmailAddresses(rejected); setError(`${rejected.join(', ')} ${rejected.length === 1 ? 'is' : 'are'} already a member.`); return; } setOpen(false); - toast.add({ type: 'success', label: plural(m.sent, emailAddresses.length, locale) }); + toast.add({ type: 'success', label: plural(m.sent, invited.length, locale) }); }, }, }; diff --git a/packages/swingset/src/stories/tag-input.component.mdx b/packages/swingset/src/stories/tag-input.component.mdx index be658d5c69a..bcfc9bfc609 100644 --- a/packages/swingset/src/stories/tag-input.component.mdx +++ b/packages/swingset/src/stories/tag-input.component.mdx @@ -19,6 +19,9 @@ A field that turns typed and pasted entries into removable tags, built on the he { name: 'value', type: 'string[]', default: '—' }, { name: 'defaultValue', type: 'string[]', default: '[]' }, { name: 'onValueChange', type: '(value: string[]) => void', default: '—' }, + { name: 'inputValue', type: 'string', default: '—' }, + { name: 'defaultInputValue', type: 'string', default: "''" }, + { name: 'onInputValueChange', type: '(inputValue: string) => void', default: '—' }, { name: 'validate', type: '(value: string) => boolean', default: '—' }, { name: 'delimiters', type: 'string[]', default: "[',']" }, { name: 'name', type: 'string', default: '—' }, diff --git a/packages/swingset/src/stories/tag-input.mdx b/packages/swingset/src/stories/tag-input.mdx index 4887700c8c8..dd951e66ebd 100644 --- a/packages/swingset/src/stories/tag-input.mdx +++ b/packages/swingset/src/stories/tag-input.mdx @@ -152,15 +152,18 @@ element. The parts (and the `useTagInput` hook) throw if used outside `TagInput. ### `TagInput.Root` -| Prop | Type | Default | Description | -| ----------------------- | ---------------------------- | ------- | ----------------------------------------------------------------------------- | -| `value` | `string[]` | — | Controlled values | -| `defaultValue` | `string[]` | `[]` | Initial values (uncontrolled) | -| `onValueChange` | `(value: string[]) => void` | — | Called with the full list on every change | -| `validate` | `(value: string) => boolean` | — | Tags returning `false` stay in the list with `data-invalid` | -| `delimiters` | `string[]` | `[',']` | Characters that commit the typed text; paste also splits on newlines and tabs | -| `name` | `string` | — | Submit each value via a hidden input under this name | -| `disabled` | `boolean` | `false` | Disable the input, removal, and the hidden inputs | +| Prop | Type | Default | Description | +| -------------------- | ------------------------------ | ------- | ----------------------------------------------------------------------------- | +| `value` | `string[]` | — | Controlled values | +| `defaultValue` | `string[]` | `[]` | Initial values (uncontrolled) | +| `onValueChange` | `(value: string[]) => void` | — | Called with the full list on every change | +| `inputValue` | `string` | — | Controlled text not yet added as a tag | +| `defaultInputValue` | `string` | `''` | Initial text (uncontrolled) | +| `onInputValueChange` | `(inputValue: string) => void` | — | Called with the text not yet added as a tag on every change | +| `validate` | `(value: string) => boolean` | — | Tags returning `false` stay in the list with `data-invalid` | +| `delimiters` | `string[]` | `[',']` | Characters that commit the typed text; paste also splits on newlines and tabs | +| `name` | `string` | — | Submit each value via a hidden input under this name | +| `disabled` | `boolean` | `false` | Disable the input, removal, and the hidden inputs | | `focusableWhenDisabled` | `boolean` | `false` | Keep the disabled input focusable, as read-only with `aria-disabled` | ### `TagInput.Tag` From 9cbe3013f02edad1b6c07e09d9c994be839c5964 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 3 Nov 2026 07:47:01 -0700 Subject: [PATCH 6/7] refactor(mosaic): drive the invite fixture with the real controller Rename pendingEmailAddresses to emailAddressesToInvite, return the invited addresses from the invite submit so the toast counts them, and drop the view-side submit guards now that the controllers own canSubmit. --- .../create-organization.controller.ts | 4 +- .../create-organization.view.tsx | 4 +- ...ation-profile-invite-members.controller.ts | 23 ++-- ...nization-profile-invite-members.dialog.tsx | 4 +- ...ganization-profile-invite-members.utils.ts | 18 ++-- .../organization-profile-invite-members.ts | 100 +++++------------- 6 files changed, 52 insertions(+), 101 deletions(-) diff --git a/packages/mosaic/src/features/create-organization/create-organization.controller.ts b/packages/mosaic/src/features/create-organization/create-organization.controller.ts index a8df8db795d..4580d91f16e 100644 --- a/packages/mosaic/src/features/create-organization/create-organization.controller.ts +++ b/packages/mosaic/src/features/create-organization/create-organization.controller.ts @@ -8,7 +8,7 @@ import type { MembersRoles } from '../organization-profile/organization-profile- import { resolveDefaultRole } from '../organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller'; import { canSendInvitations, - pendingEmailAddresses, + emailAddressesToInvite, } from '../organization-profile/organization-profile-members-panel/organization-profile-invite-members.utils'; import type { CreateOrganizationModel } from './create-organization.model'; import type { @@ -138,7 +138,7 @@ export function useCreateOrganizationInviteController({ return false; } await onInvite(organizationId, { - emailAddresses: pendingEmailAddresses(values), + emailAddresses: emailAddressesToInvite(values), role: selected, onRejected: setRejectedEmailAddresses, }); diff --git a/packages/mosaic/src/features/create-organization/create-organization.view.tsx b/packages/mosaic/src/features/create-organization/create-organization.view.tsx index c0830ca9511..071e2ebb2f4 100644 --- a/packages/mosaic/src/features/create-organization/create-organization.view.tsx +++ b/packages/mosaic/src/features/create-organization/create-organization.view.tsx @@ -228,9 +228,7 @@ function InviteStep({ invite, onFinish }: CreateOrganizationStepsProps) { id={formId} onSubmit={event => { event.preventDefault(); - if (invite.canSubmit && !invite.isPending) { - invite.onSubmit(); - } + invite.onSubmit(); }} /> } diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller.ts b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller.ts index 168a4b9d050..c72985b4761 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller.ts @@ -7,7 +7,7 @@ import { plural, useLocale, useMessages } from '../../../localization'; import type { MembersRoles } from './members-table-tab.types'; import type { OrganizationProfileInviteMembersDialogProps } from './organization-profile-invite-members.dialog'; import type { InviteMembersInput } from './organization-profile-invite-members.model'; -import { canSendInvitations, pendingEmailAddresses } from './organization-profile-invite-members.utils'; +import { canSendInvitations, emailAddressesToInvite } from './organization-profile-invite-members.utils'; interface InviteMembersValues { emailAddresses: string[]; @@ -31,29 +31,24 @@ export function useInviteMembersController({ roles, defaultRole, invite }: Invit const [rejectedEmailAddresses, setRejectedEmailAddresses] = useState([]); const initialRole = roles ? resolveDefaultRole(roles, defaultRole) : null; - const form = useForm({ + const form = useForm({ initialValues: { emailAddresses: [], emailDraft: '', role: initialRole }, errorFallback: m.error, canSubmit: values => canSendInvitations({ ...values, rejectedEmailAddresses }), - onSubmit: async values => { - const { role } = values; + onSubmit: async ({ role, ...draft }) => { if (!invite || !roles || role === null) { - return false; + return null; } - await invite({ - emailAddresses: pendingEmailAddresses(values), - role, - roles, - onRejected: setRejectedEmailAddresses, - }); - return true; + const emailAddresses = emailAddressesToInvite(draft); + await invite({ emailAddresses, role, roles, onRejected: setRejectedEmailAddresses }); + return emailAddresses; }, - onSuccess: (invited, values) => { + onSuccess: invited => { if (!invited) { return; } setOpen(false); - toast.add({ type: 'success', label: plural(m.sent, pendingEmailAddresses(values).length, locale) }); + toast.add({ type: 'success', label: plural(m.sent, invited.length, locale) }); }, }); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.dialog.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.dialog.tsx index 992e64100b2..93f049888b3 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.dialog.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.dialog.tsx @@ -83,9 +83,7 @@ export function OrganizationProfileInviteMembersDialog({ id={formId} onSubmit={event => { event.preventDefault(); - if (canSubmit && !isPending) { - void onSubmit(); - } + void onSubmit(); }} /> } diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.utils.ts b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.utils.ts index 08dbe889013..ad3ea536371 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.utils.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.utils.ts @@ -1,8 +1,11 @@ const isEmail = (value: string) => /^\S+@\S+\.\S+$/.test(value); -interface InviteMembersValidity { +interface InviteMembersDraft { emailAddresses: string[]; emailDraft: string; +} + +interface InviteMembersValidity extends InviteMembersDraft { rejectedEmailAddresses: string[]; role: string | null; } @@ -15,15 +18,14 @@ export function hasInvalidEmailAddresses(emailAddresses: string[], rejectedEmail return !emailAddresses.every(value => isValidEmail(value, rejectedEmailAddresses)); } -export function pendingEmailAddresses({ - emailAddresses, - emailDraft, -}: Pick) { +export function emailAddressesToInvite({ emailAddresses, emailDraft }: InviteMembersDraft) { const draft = emailDraft.trim(); return draft && !emailAddresses.includes(draft) ? [...emailAddresses, draft] : emailAddresses; } -export function canSendInvitations({ rejectedEmailAddresses, role, ...input }: InviteMembersValidity) { - const pending = pendingEmailAddresses(input); - return pending.length > 0 && !hasInvalidEmailAddresses(pending, rejectedEmailAddresses) && role !== null; +export function canSendInvitations({ rejectedEmailAddresses, role, ...draft }: InviteMembersValidity) { + const emailAddresses = emailAddressesToInvite(draft); + return ( + emailAddresses.length > 0 && !hasInvalidEmailAddresses(emailAddresses, rejectedEmailAddresses) && role !== null + ); } diff --git a/packages/swingset/src/stories/fixtures/organization-profile-invite-members.ts b/packages/swingset/src/stories/fixtures/organization-profile-invite-members.ts index 935faa6331a..b343737d505 100644 --- a/packages/swingset/src/stories/fixtures/organization-profile-invite-members.ts +++ b/packages/swingset/src/stories/fixtures/organization-profile-invite-members.ts @@ -1,80 +1,38 @@ -import { useToastManager } from '@clerk/mosaic/components/toast'; -import type { OrganizationProfileInviteMembersDialogProps } from '@clerk/mosaic/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.dialog'; -import { - canSendInvitations, - pendingEmailAddresses, -} from '@clerk/mosaic/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.utils'; -import { plural, useLocale, useMessages } from '@clerk/mosaic/localization'; -import type { MouseEvent } from 'react'; -import { useRef, useState } from 'react'; +import type { MembersRoles } from '@clerk/mosaic/features/organization-profile/organization-profile-members-panel/members-table-tab.types'; +import { useInviteMembersController } from '@clerk/mosaic/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller'; +import type { InviteMembersInput } from '@clerk/mosaic/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.model'; +import { SaveError } from '@clerk/mosaic/utils/errors'; import { useChaosFixture } from '@/components/ChaosProvider'; import { chaosRole } from '@/lib/chaos'; -const exampleRoles = [ - { value: 'member', label: 'Member', description: 'Role with non-privileged permissions in the organization.' }, - { value: 'admin', label: 'Admin', description: 'Role with elevated permissions in the organization.' }, -]; +const exampleRoles: MembersRoles = { + roles: [ + { key: 'org:member', name: 'Member' }, + { key: 'org:admin', name: 'Admin' }, + ], + hasRoleSetMigration: false, +}; const existingMembers = ['ada.lovelace@example.com', 'grace.hopper@example.com']; -export function useInviteMembersFixture(): { - onInvite: (event: MouseEvent) => void; - inviteDialog: OrganizationProfileInviteMembersDialogProps; -} { - const m = useMessages('organizationProfile').members.invite; - const locale = useLocale(); - const roles = useChaosFixture(exampleRoles, items => items.map((role, index) => ({ ...role, ...chaosRole(index) }))); - const toast = useToastManager(); - const trigger = useRef(null); - const [open, setOpen] = useState(false); - const [emailAddresses, setEmailAddresses] = useState([]); - const [emailDraft, setEmailDraft] = useState(''); - const [rejectedEmailAddresses, setRejectedEmailAddresses] = useState([]); - const [role, setRole] = useState('member'); - const [isPending, setIsPending] = useState(false); - const [error, setError] = useState(null); +async function invite({ emailAddresses, onRejected }: InviteMembersInput) { + await new Promise(resolve => setTimeout(resolve, 800)); + const rejected = emailAddresses.filter(email => existingMembers.includes(email)); + if (rejected.length > 0) { + onRejected(rejected); + throw new SaveError({ global: { code: 'already_a_member_in_organization' } }); + } +} - return { - onInvite: event => { - trigger.current = event.currentTarget; - setEmailAddresses([]); - setEmailDraft(''); - setRejectedEmailAddresses([]); - setRole('member'); - setError(null); - setOpen(true); - }, - inviteDialog: { - open, - onOpenChange: setOpen, - finalFocus: trigger, - emailAddresses, - onEmailAddressesChange: setEmailAddresses, - emailDraft, - onEmailDraftChange: setEmailDraft, - rejectedEmailAddresses, - roles, - role, - onRoleChange: setRole, - canSubmit: canSendInvitations({ emailAddresses, emailDraft, rejectedEmailAddresses, role }), - isPending, - error, - onSubmit: async () => { - setIsPending(true); - setError(null); - await new Promise(resolve => setTimeout(resolve, 800)); - setIsPending(false); - const invited = pendingEmailAddresses({ emailAddresses, emailDraft }); - const rejected = invited.filter(email => existingMembers.includes(email)); - if (rejected.length > 0) { - setRejectedEmailAddresses(rejected); - setError(`${rejected.join(', ')} ${rejected.length === 1 ? 'is' : 'are'} already a member.`); - return; - } - setOpen(false); - toast.add({ type: 'success', label: plural(m.sent, invited.length, locale) }); - }, - }, - }; +export function useInviteMembersFixture() { + const roles = useChaosFixture(exampleRoles, value => ({ + ...value, + roles: value.roles.map((role, index) => ({ ...role, name: chaosRole(index).label })), + })); + const { onInvite, inviteDialog } = useInviteMembersController({ roles, defaultRole: 'org:member', invite }); + if (!onInvite || !inviteDialog) { + throw new Error('The invite members fixture always passes invite and roles.'); + } + return { onInvite, inviteDialog }; } From 7f6bd7ed61b0e5791270f85346ee6d979547186f Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 3 Nov 2026 16:08:40 -0700 Subject: [PATCH 7/7] style(swingset): format combined tag input props --- packages/swingset/src/stories/tag-input.mdx | 26 ++++++++++----------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/swingset/src/stories/tag-input.mdx b/packages/swingset/src/stories/tag-input.mdx index dd951e66ebd..6fef0fa07b8 100644 --- a/packages/swingset/src/stories/tag-input.mdx +++ b/packages/swingset/src/stories/tag-input.mdx @@ -152,19 +152,19 @@ element. The parts (and the `useTagInput` hook) throw if used outside `TagInput. ### `TagInput.Root` -| Prop | Type | Default | Description | -| -------------------- | ------------------------------ | ------- | ----------------------------------------------------------------------------- | -| `value` | `string[]` | — | Controlled values | -| `defaultValue` | `string[]` | `[]` | Initial values (uncontrolled) | -| `onValueChange` | `(value: string[]) => void` | — | Called with the full list on every change | -| `inputValue` | `string` | — | Controlled text not yet added as a tag | -| `defaultInputValue` | `string` | `''` | Initial text (uncontrolled) | -| `onInputValueChange` | `(inputValue: string) => void` | — | Called with the text not yet added as a tag on every change | -| `validate` | `(value: string) => boolean` | — | Tags returning `false` stay in the list with `data-invalid` | -| `delimiters` | `string[]` | `[',']` | Characters that commit the typed text; paste also splits on newlines and tabs | -| `name` | `string` | — | Submit each value via a hidden input under this name | -| `disabled` | `boolean` | `false` | Disable the input, removal, and the hidden inputs | -| `focusableWhenDisabled` | `boolean` | `false` | Keep the disabled input focusable, as read-only with `aria-disabled` | +| Prop | Type | Default | Description | +| ----------------------- | ------------------------------ | ------- | ----------------------------------------------------------------------------- | +| `value` | `string[]` | — | Controlled values | +| `defaultValue` | `string[]` | `[]` | Initial values (uncontrolled) | +| `onValueChange` | `(value: string[]) => void` | — | Called with the full list on every change | +| `inputValue` | `string` | — | Controlled text not yet added as a tag | +| `defaultInputValue` | `string` | `''` | Initial text (uncontrolled) | +| `onInputValueChange` | `(inputValue: string) => void` | — | Called with the text not yet added as a tag on every change | +| `validate` | `(value: string) => boolean` | — | Tags returning `false` stay in the list with `data-invalid` | +| `delimiters` | `string[]` | `[',']` | Characters that commit the typed text; paste also splits on newlines and tabs | +| `name` | `string` | — | Submit each value via a hidden input under this name | +| `disabled` | `boolean` | `false` | Disable the input, removal, and the hidden inputs | +| `focusableWhenDisabled` | `boolean` | `false` | Keep the disabled input focusable, as read-only with `aria-disabled` | ### `TagInput.Tag`