Skip to content

Commit 63bea21

Browse files
Add prefix and postfix support in react sdk
Signed-off-by: sajid.mannikeri <sajid.mannikeri@infosys.com>
1 parent 3e258a6 commit 63bea21

10 files changed

Lines changed: 479 additions & 9 deletions

File tree

packages/javascript/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export type {
7171
ValidationRule,
7272
ValidationRuleType,
7373
FieldError,
74+
PrefixOption,
7475
} from './models/embedded-flow';
7576
export {EmbeddedSignInFlowStatus, EmbeddedSignInFlowType} from './models/embedded-signin-flow';
7677
export type {

packages/javascript/src/models/embedded-flow.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,10 @@ export interface EmbeddedFlowComponent {
402402
*/
403403
placeholder?: string;
404404

405+
postfix?: string;
406+
407+
prefixes?: string | PrefixOption[];
408+
405409
/**
406410
* Reference identifier for the component (e.g., field name, action ref)
407411
*/
@@ -508,6 +512,17 @@ export interface FieldError {
508512
message: string;
509513
}
510514

515+
export interface PrefixOption {
516+
/** The value of the prefix option (e.g., country code, currency symbol). */
517+
value: string;
518+
/** Optional label for the prefix option (e.g., "United States (+1)"). */
519+
label: string;
520+
/** Optional icon URL for the prefix option (e.g., flag image). */
521+
maxLength?: number;
522+
523+
regex?: string;
524+
}
525+
511526
/**
512527
* Response data structure for embedded flow API.
513528
*

packages/react/src/components/presentation/auth/AuthOptionFactory.tsx

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,10 @@ import {
3434
ConsentPurposeDecision,
3535
ConsentAttributeElement,
3636
OrganizationUnitListResponse,
37+
PrefixOption,
3738
} from '@thunderid/browser';
3839
import DOMPurify from 'dompurify';
39-
import {cloneElement, CSSProperties, ReactElement} from 'react';
40+
import {ChangeEvent, cloneElement, CSSProperties, ReactElement} from 'react';
4041
import {
4142
ComponentRenderer,
4243
ComponentRenderContext,
@@ -60,7 +61,9 @@ import CopyableText from '../../primitives/CopyableText/CopyableText';
6061
import DatePicker from '../../primitives/DatePicker/DatePicker';
6162
import Divider from '../../primitives/Divider/Divider';
6263
import flowIconRegistry from '../../primitives/Icons/flowIconRegistry';
64+
import AffixedField from '../../primitives/AffixedField/AffixedField';
6365
import Select from '../../primitives/Select/Select';
66+
import {affixPostfixKey, affixPrefixKey} from '../../../utils/composeAffixedInputs';
6467
import Typography from '../../primitives/Typography/Typography';
6568
import {TypographyVariant} from '../../primitives/Typography/Typography.styles';
6669

@@ -257,6 +260,52 @@ const createAuthComponentFromFlow = (
257260
const error: string = isTouched ? formErrors[identifier] : undefined!;
258261
const fieldType: string = getFieldType(component.type);
259262

263+
// Prefix/postfix are accepted on any text-family input. When either is present,
264+
// route to AffixedField so the leading affix renders and submit-time composition
265+
// is picked up by composeAffixedInputs via the sentinel keys.
266+
// Both string.length and Array.length are defined, so a single truthy+length
267+
// check covers `prefixes?: string | PrefixOption[]`.
268+
const hasPrefixes: boolean = !!component.prefixes && component.prefixes.length > 0;
269+
const hasPostfix: boolean = typeof component.postfix === 'string' && component.postfix.length > 0;
270+
271+
if (hasPrefixes || hasPostfix) {
272+
const prefixStateKey: string = affixPrefixKey(identifier);
273+
const postfixStateKey: string = affixPostfixKey(identifier);
274+
const defaultPrefixValue: string = hasPrefixes
275+
? typeof component.prefixes === 'string'
276+
? component.prefixes
277+
: (component.prefixes as PrefixOption[])[0].value
278+
: '';
279+
const selectedPrefix: string = formValues[prefixStateKey] ?? defaultPrefixValue;
280+
281+
return (
282+
<AffixedField
283+
key={key}
284+
className={options.inputClassName}
285+
label={resolve(component.label) || ''}
286+
name={identifier}
287+
placeholder={resolve(component.placeholder) || ''}
288+
required={component.required || false}
289+
error={error}
290+
value={value}
291+
type={fieldType}
292+
prefixes={component.prefixes}
293+
prefixValue={selectedPrefix}
294+
onChange={(e: ChangeEvent<HTMLInputElement>): void => onInputChange(identifier, e.target.value)}
295+
onBlur={(): void => options.onInputBlur?.(identifier)}
296+
onPrefixChange={(newPrefix: string): void => onInputChange(prefixStateKey, newPrefix)}
297+
onFocus={(): void => {
298+
if (hasPrefixes && formValues[prefixStateKey] === undefined) {
299+
onInputChange(prefixStateKey, defaultPrefixValue);
300+
}
301+
if (hasPostfix && formValues[postfixStateKey] === undefined) {
302+
onInputChange(postfixStateKey, component.postfix!);
303+
}
304+
}}
305+
/>
306+
);
307+
}
308+
260309
const field: any = createField({
261310
className: cx(options.inputClassName, component.classes),
262311
error,

packages/react/src/components/presentation/auth/Recovery/BaseRecovery.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import useTheme from '../../../../contexts/Theme/useTheme';
3939
import useThunderID from '../../../../contexts/ThunderID/useThunderID';
4040
import {useForm, FormField} from '../../../../hooks/useForm';
4141
import useTranslation from '../../../../hooks/useTranslation';
42+
import composeAffixedInputs from '../../../../utils/composeAffixedInputs';
4243
import {normalizeFlowResponse, extractErrorMessage} from '../../../../utils/flowTransformer';
4344
import getAuthComponentHeadings from '../../../../utils/getAuthComponentHeadings';
4445
import AlertPrimitive from '../../../primitives/Alert/Alert';
@@ -305,9 +306,13 @@ const BaseRecoveryContent: FC<BaseRecoveryProps> = ({
305306
clearMessages();
306307

307308
try {
309+
const composedData: Record<string, any> | undefined = data
310+
? composeAffixedInputs(data as Record<string, string>)
311+
: undefined;
312+
308313
const filteredInputs: Record<string, any> = {};
309-
if (data) {
310-
Object.entries(data).forEach(([key, value]: [string, any]) => {
314+
if (composedData) {
315+
Object.entries(composedData).forEach(([key, value]: [string, any]) => {
311316
if (value !== null && value !== undefined && value !== '') {
312317
filteredInputs[key] = value;
313318
}

packages/react/src/components/presentation/auth/SignIn/BaseSignIn.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import useTheme from '../../../../contexts/Theme/useTheme';
3737
import useThunderID from '../../../../contexts/ThunderID/useThunderID';
3838
import {FormField, useForm} from '../../../../hooks/useForm';
3939
import useTranslation from '../../../../hooks/useTranslation';
40+
import composeAffixedInputs from '../../../../utils/composeAffixedInputs';
4041
import {extractErrorMessage} from '../../../../utils/flowTransformer';
4142
import AlertPrimitive from '../../../primitives/Alert/Alert';
4243
// eslint-disable-next-line import/no-named-as-default
@@ -401,12 +402,16 @@ const BaseSignInContent: FC<BaseSignInProps> = ({
401402
clearMessages();
402403

403404
try {
405+
const composedData: Record<string, any> | undefined = data
406+
? composeAffixedInputs(data as Record<string, string>)
407+
: undefined;
408+
404409
// Filter out empty or undefined input values
405410
const filteredInputs: Record<string, any> = {};
406-
if (data) {
407-
Object.keys(data).forEach((key: any) => {
408-
if (data[key] !== undefined && data[key] !== null && data[key] !== '') {
409-
filteredInputs[key] = data[key];
411+
if (composedData) {
412+
Object.keys(composedData).forEach((key: any) => {
413+
if (composedData[key] !== undefined && composedData[key] !== null && composedData[key] !== '') {
414+
filteredInputs[key] = composedData[key];
410415
}
411416
});
412417
}

packages/react/src/components/presentation/auth/SignUp/BaseSignUp.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import useTheme from '../../../../contexts/Theme/useTheme';
4141
import useThunderID from '../../../../contexts/ThunderID/useThunderID';
4242
import {useForm, FormField} from '../../../../hooks/useForm';
4343
import useTranslation from '../../../../hooks/useTranslation';
44+
import composeAffixedInputs from '../../../../utils/composeAffixedInputs';
4445
import {normalizeFlowResponse, extractErrorMessage} from '../../../../utils/flowTransformer';
4546
import getAuthComponentHeadings from '../../../../utils/getAuthComponentHeadings';
4647
import {handlePasskeyRegistration} from '../../../../utils/passkey';
@@ -795,10 +796,14 @@ const BaseSignUpContent: FC<BaseSignUpProps> = ({
795796
clearMessages();
796797

797798
try {
799+
const composedData: Record<string, any> | undefined = data
800+
? composeAffixedInputs(data as Record<string, string>)
801+
: undefined;
802+
798803
// Filter out empty or undefined input values
799804
const filteredInputs: Record<string, any> = {};
800-
if (data) {
801-
Object.entries(data).forEach(([key, value]: [string, any]) => {
805+
if (composedData) {
806+
Object.entries(composedData).forEach(([key, value]: [string, any]) => {
802807
if (value !== null && value !== undefined && value !== '') {
803808
filteredInputs[key] = value;
804809
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
3+
*
4+
* WSO2 LLC. licenses this file to you under the Apache License,
5+
* Version 2.0 (the "License"); you may not use this file except
6+
* in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing,
12+
* software distributed under the License is distributed on an
13+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
* KIND, either express or implied. See the License for the
15+
* specific language governing permissions and limitations
16+
* under the License.
17+
*/
18+
19+
import {css} from '@emotion/css';
20+
import {Theme} from '@thunderid/browser';
21+
import {useMemo} from 'react';
22+
23+
/**
24+
* Creates styles for the AffixedField component. The container is a horizontal flex
25+
* row with a prefix element (span or select) joined to a shared-border input.
26+
*/
27+
const useStyles = (
28+
theme: Theme,
29+
colorScheme: string,
30+
disabled: boolean,
31+
hasError: boolean,
32+
hasPrefix: boolean,
33+
): Record<string, string> =>
34+
useMemo(() => {
35+
const borderColor: string = hasError ? theme.vars.colors.error.main : theme.vars.colors.border;
36+
const focusColor: string = hasError ? theme.vars.colors.error.main : theme.vars.colors.primary.main;
37+
const borderRadius: string = theme.vars.components?.Field?.root?.borderRadius || theme.vars.borderRadius.medium;
38+
39+
const inputContainer: string = css`
40+
position: relative;
41+
display: flex;
42+
align-items: stretch;
43+
width: 100%;
44+
`;
45+
46+
const inputBase: string = css`
47+
flex: 1;
48+
min-width: 0;
49+
padding-block: ${theme.vars.spacing.unit};
50+
padding-inline: calc(${theme.vars.spacing.unit} * 1.5);
51+
border: 1px solid ${borderColor};
52+
font-size: ${theme.vars.typography.fontSizes.md};
53+
font-family: ${theme.vars.typography.fontFamily};
54+
color: ${theme.vars.colors.text.primary};
55+
background-color: ${disabled ? theme.vars.colors.background.disabled : theme.vars.colors.background.surface};
56+
outline: none;
57+
transition:
58+
border-color 0.2s ease,
59+
box-shadow 0.2s ease;
60+
61+
&::placeholder {
62+
color: ${theme.vars.colors.text.secondary};
63+
opacity: 0.7;
64+
}
65+
66+
&:focus {
67+
border-color: ${focusColor};
68+
box-shadow: 0 0 0 2px ${focusColor}20;
69+
}
70+
71+
&:disabled {
72+
opacity: 0.6;
73+
cursor: not-allowed;
74+
}
75+
76+
&:hover:not(:disabled) {
77+
border-color: ${focusColor};
78+
}
79+
`;
80+
81+
const input: string = hasPrefix
82+
? css`
83+
${inputBase};
84+
border-start-start-radius: 0;
85+
border-end-start-radius: 0;
86+
border-start-end-radius: ${borderRadius};
87+
border-end-end-radius: ${borderRadius};
88+
border-inline-start: none;
89+
`
90+
: css`
91+
${inputBase};
92+
border-radius: ${borderRadius};
93+
`;
94+
95+
const prefixBase: string = css`
96+
display: flex;
97+
align-items: center;
98+
padding-inline: calc(${theme.vars.spacing.unit} * 1.5);
99+
border: 1px solid ${borderColor};
100+
border-start-start-radius: ${borderRadius};
101+
border-end-start-radius: ${borderRadius};
102+
background-color: ${theme.vars.colors.background.surface};
103+
color: ${theme.vars.colors.text.primary};
104+
font-size: ${theme.vars.typography.fontSizes.md};
105+
font-family: ${theme.vars.typography.fontFamily};
106+
white-space: nowrap;
107+
`;
108+
109+
const prefixSpan: string = css`
110+
${prefixBase};
111+
user-select: none;
112+
color: ${theme.vars.colors.text.secondary};
113+
`;
114+
115+
const prefixSelect: string = css`
116+
${prefixBase};
117+
cursor: pointer;
118+
appearance: auto;
119+
padding-inline-end: calc(${theme.vars.spacing.unit} * 1.5);
120+
121+
&:disabled {
122+
opacity: 0.6;
123+
cursor: not-allowed;
124+
}
125+
126+
&:focus {
127+
outline: none;
128+
border-color: ${focusColor};
129+
box-shadow: 0 0 0 2px ${focusColor}20;
130+
z-index: 1;
131+
}
132+
`;
133+
134+
return {
135+
input,
136+
inputContainer,
137+
prefixSelect,
138+
prefixSpan,
139+
};
140+
}, [theme, colorScheme, disabled, hasError, hasPrefix]);
141+
142+
export default useStyles;

0 commit comments

Comments
 (0)