From 706206826d2798b34d3f1bdcbc3258daaa7a7786 Mon Sep 17 00:00:00 2001 From: "sajid.mannikeri" Date: Mon, 13 Jul 2026 10:51:49 +0530 Subject: [PATCH] Add prefix and postfix support in react sdk Signed-off-by: sajid.mannikeri --- packages/javascript/src/index.ts | 1 + .../javascript/src/models/embedded-flow.ts | 15 ++ .../presentation/auth/AuthOptionFactory.tsx | 62 ++++++- .../auth/Recovery/BaseRecovery.tsx | 13 +- .../presentation/auth/SignIn/BaseSignIn.tsx | 17 +- .../presentation/auth/SignUp/BaseSignUp.tsx | 13 +- .../AffixedField/AffixedField.styles.ts | 160 +++++++++++++++++ .../primitives/AffixedField/AffixedField.tsx | 169 ++++++++++++++++++ .../primitives/AffixedField/index.ts | 20 +++ .../react/src/utils/composeAffixedInputs.ts | 100 +++++++++++ 10 files changed, 558 insertions(+), 12 deletions(-) create mode 100644 packages/react/src/components/primitives/AffixedField/AffixedField.styles.ts create mode 100644 packages/react/src/components/primitives/AffixedField/AffixedField.tsx create mode 100644 packages/react/src/components/primitives/AffixedField/index.ts create mode 100644 packages/react/src/utils/composeAffixedInputs.ts diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index 62b2ba3..0040dcf 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -71,6 +71,7 @@ export type { ValidationRule, ValidationRuleType, FieldError, + PrefixOption, } from './models/embedded-flow'; export {EmbeddedSignInFlowStatus, EmbeddedSignInFlowType} from './models/embedded-signin-flow'; export type { diff --git a/packages/javascript/src/models/embedded-flow.ts b/packages/javascript/src/models/embedded-flow.ts index 7162721..7239bb8 100644 --- a/packages/javascript/src/models/embedded-flow.ts +++ b/packages/javascript/src/models/embedded-flow.ts @@ -402,6 +402,10 @@ export interface EmbeddedFlowComponent { */ placeholder?: string; + postfix?: string; + + prefixes?: string | PrefixOption[]; + /** * Reference identifier for the component (e.g., field name, action ref) */ @@ -508,6 +512,17 @@ export interface FieldError { message: string; } +export interface PrefixOption { + /** The value of the prefix option (e.g., country code, currency symbol). */ + value: string; + /** Display label for the prefix option (e.g., "India (+91)"). */ + label: string; + /** Optional maximum length constraint for input when this prefix is selected. */ + maxLength?: number; + /** Optional regex pattern for validating input when this prefix is selected. */ + regex?: string; +} + /** * Response data structure for embedded flow API. * diff --git a/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx b/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx index 4be53ec..b85541a 100644 --- a/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx +++ b/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx @@ -34,9 +34,10 @@ import { ConsentPurposeDecision, ConsentAttributeElement, OrganizationUnitListResponse, + PrefixOption, } from '@thunderid/browser'; import DOMPurify from 'dompurify'; -import {cloneElement, CSSProperties, ReactElement} from 'react'; +import {ChangeEvent, cloneElement, CSSProperties, ReactElement} from 'react'; import { ComponentRenderer, ComponentRenderContext, @@ -60,7 +61,9 @@ import CopyableText from '../../primitives/CopyableText/CopyableText'; import DatePicker from '../../primitives/DatePicker/DatePicker'; import Divider from '../../primitives/Divider/Divider'; import flowIconRegistry from '../../primitives/Icons/flowIconRegistry'; +import AffixedField from '../../primitives/AffixedField/AffixedField'; import Select from '../../primitives/Select/Select'; +import {affixPostfixKey, affixPrefixKey} from '../../../utils/composeAffixedInputs'; import Typography from '../../primitives/Typography/Typography'; import {TypographyVariant} from '../../primitives/Typography/Typography.styles'; @@ -212,6 +215,8 @@ const createAuthComponentFromFlow = ( /** Translation function for resolving {{t(...)}} expressions at render time */ t?: UseTranslation['t']; variant?: any; + /** Vendor namespace used to derive affix sentinel keys for prefix/postfix-bearing inputs. */ + vendor?: string; } = {}, ): ReactElement | null => { const theme: any = options._theme; @@ -257,6 +262,53 @@ const createAuthComponentFromFlow = ( const error: string = isTouched ? formErrors[identifier] : undefined!; const fieldType: string = getFieldType(component.type); + // Prefix/postfix are accepted on any text-family input. When either is present, + // route to AffixedField so the leading affix renders and submit-time composition + // is picked up by composeAffixedInputs via the sentinel keys. + // Both string.length and Array.length are defined, so a single truthy+length + // check covers `prefixes?: string | PrefixOption[]`. + const hasPrefixes: boolean = !!component.prefixes && component.prefixes.length > 0; + const hasPostfix: boolean = typeof component.postfix === 'string' && component.postfix.length > 0; + + if (hasPrefixes || hasPostfix) { + const prefixStateKey: string = affixPrefixKey(identifier, options.vendor); + const postfixStateKey: string = affixPostfixKey(identifier, options.vendor); + const defaultPrefixValue: string = hasPrefixes + ? typeof component.prefixes === 'string' + ? component.prefixes + : (component.prefixes as PrefixOption[])[0].value + : ''; + const selectedPrefix: string = formValues[prefixStateKey] ?? defaultPrefixValue; + + return ( + ): void => onInputChange(identifier, e.target.value)} + onBlur={(): void => options.onInputBlur?.(identifier)} + onPrefixChange={(newPrefix: string): void => onInputChange(prefixStateKey, newPrefix)} + onFocus={(): void => { + if (hasPrefixes && formValues[prefixStateKey] === undefined) { + onInputChange(prefixStateKey, defaultPrefixValue); + } + if (hasPostfix && formValues[postfixStateKey] === undefined) { + onInputChange(postfixStateKey, component.postfix!); + } + }} + /> + ); + } + const field: any = createField({ className: cx(options.inputClassName, component.classes), error, @@ -756,6 +808,8 @@ export const renderSignInComponents = ( /** Translation function for resolving {{t(...)}} expressions at render time */ t?: UseTranslation['t']; variant?: any; + /** Vendor namespace used to derive affix sentinel keys for prefix/postfix-bearing inputs. */ + vendor?: string; }, ): ReactElement[] => components @@ -805,6 +859,8 @@ export const renderSignUpComponents = ( /** Translation function for resolving {{t(...)}} expressions at render time */ t?: UseTranslation['t']; variant?: any; + /** Vendor namespace used to derive affix sentinel keys for prefix/postfix-bearing inputs. */ + vendor?: string; }, ): ReactElement[] => components @@ -856,6 +912,8 @@ export const renderRecoveryComponents = ( /** Translation function for resolving {{t(...)}} expressions at render time */ t?: UseTranslation['t']; variant?: any; + /** Vendor namespace used to derive affix sentinel keys for prefix/postfix-bearing inputs. */ + vendor?: string; }, ): ReactElement[] => components @@ -912,6 +970,8 @@ export const renderInviteUserComponents = ( /** Translation function for resolving {{t(...)}} expressions at render time */ t?: UseTranslation['t']; variant?: any; + /** Vendor namespace used to derive affix sentinel keys for prefix/postfix-bearing inputs. */ + vendor?: string; }, ): ReactElement[] => components diff --git a/packages/react/src/components/presentation/auth/Recovery/BaseRecovery.tsx b/packages/react/src/components/presentation/auth/Recovery/BaseRecovery.tsx index b21adb7..e54ddbc 100644 --- a/packages/react/src/components/presentation/auth/Recovery/BaseRecovery.tsx +++ b/packages/react/src/components/presentation/auth/Recovery/BaseRecovery.tsx @@ -39,6 +39,7 @@ import useTheme from '../../../../contexts/Theme/useTheme'; import useThunderID from '../../../../contexts/ThunderID/useThunderID'; import {useForm, FormField} from '../../../../hooks/useForm'; import useTranslation from '../../../../hooks/useTranslation'; +import composeAffixedInputs from '../../../../utils/composeAffixedInputs'; import {normalizeFlowResponse, extractErrorMessage} from '../../../../utils/flowTransformer'; import getAuthComponentHeadings from '../../../../utils/getAuthComponentHeadings'; import AlertPrimitive from '../../../primitives/Alert/Alert'; @@ -129,7 +130,7 @@ const BaseRecoveryContent: FC = ({ const customRenderers: ComponentRendererMap = useContext(ComponentRendererContext); const {t} = useTranslation(); const {subtitle: flowSubtitle, title: flowTitle, messages: flowMessages, addMessage, clearMessages} = useFlow(); - const {meta} = useThunderID(); + const {meta, vendor} = useThunderID(); const styles: any = useStyles(theme, colorScheme); const [isLoading, setIsLoading] = useState(false); @@ -310,9 +311,13 @@ const BaseRecoveryContent: FC = ({ clearMessages(); try { + const composedData: Record | undefined = data + ? composeAffixedInputs(data as Record, vendor) + : undefined; + const filteredInputs: Record = {}; - if (data) { - Object.entries(data).forEach(([key, value]: [string, any]) => { + if (composedData) { + Object.entries(composedData).forEach(([key, value]: [string, any]) => { if (value !== null && value !== undefined && value !== '') { filteredInputs[key] = value; } @@ -414,6 +419,7 @@ const BaseRecoveryContent: FC = ({ onSubmit: handleSubmit, size, variant, + vendor, }, ), [ @@ -431,6 +437,7 @@ const BaseRecoveryContent: FC = ({ theme, touchedFields, variant, + vendor, ], ); diff --git a/packages/react/src/components/presentation/auth/SignIn/BaseSignIn.tsx b/packages/react/src/components/presentation/auth/SignIn/BaseSignIn.tsx index d6426a0..f487499 100644 --- a/packages/react/src/components/presentation/auth/SignIn/BaseSignIn.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/BaseSignIn.tsx @@ -38,6 +38,7 @@ import useTheme from '../../../../contexts/Theme/useTheme'; import useThunderID from '../../../../contexts/ThunderID/useThunderID'; import {FormField, useForm} from '../../../../hooks/useForm'; import useTranslation from '../../../../hooks/useTranslation'; +import composeAffixedInputs from '../../../../utils/composeAffixedInputs'; import {extractErrorMessage} from '../../../../utils/flowTransformer'; import AlertPrimitive from '../../../primitives/Alert/Alert'; // eslint-disable-next-line import/no-named-as-default @@ -257,7 +258,7 @@ const BaseSignInContent: FC = ({ isTimeoutDisabled = false, serverFieldErrors = null, }: BaseSignInProps): ReactElement => { - const {meta} = useThunderID(); + const {meta, vendor} = useThunderID(); const {theme} = useTheme(); const customRenderers: ComponentRendererMap = useContext(ComponentRendererContext); const {t} = useTranslation(); @@ -439,12 +440,16 @@ const BaseSignInContent: FC = ({ clearMessages(); try { + const composedData: Record | undefined = data + ? composeAffixedInputs(data as Record, vendor) + : undefined; + // Filter out empty or undefined input values const filteredInputs: Record = {}; - if (data) { - Object.keys(data).forEach((key: any) => { - if (data[key] !== undefined && data[key] !== null && data[key] !== '') { - filteredInputs[key] = data[key]; + if (composedData) { + Object.keys(composedData).forEach((key: any) => { + if (composedData[key] !== undefined && composedData[key] !== null && composedData[key] !== '') { + filteredInputs[key] = composedData[key]; } }); } @@ -523,6 +528,7 @@ const BaseSignInContent: FC = ({ size, t, variant, + vendor, }, ), [ @@ -538,6 +544,7 @@ const BaseSignInContent: FC = ({ isLoading, size, variant, + vendor, inputClasses, buttonClasses, handleInputBlur, diff --git a/packages/react/src/components/presentation/auth/SignUp/BaseSignUp.tsx b/packages/react/src/components/presentation/auth/SignUp/BaseSignUp.tsx index 5b1476c..af9f093 100644 --- a/packages/react/src/components/presentation/auth/SignUp/BaseSignUp.tsx +++ b/packages/react/src/components/presentation/auth/SignUp/BaseSignUp.tsx @@ -41,6 +41,7 @@ import useTheme from '../../../../contexts/Theme/useTheme'; import useThunderID from '../../../../contexts/ThunderID/useThunderID'; import {useForm, FormField} from '../../../../hooks/useForm'; import useTranslation from '../../../../hooks/useTranslation'; +import composeAffixedInputs from '../../../../utils/composeAffixedInputs'; import {normalizeFlowResponse, extractErrorMessage} from '../../../../utils/flowTransformer'; import getAuthComponentHeadings from '../../../../utils/getAuthComponentHeadings'; import {handlePasskeyRegistration} from '../../../../utils/passkey'; @@ -286,7 +287,7 @@ const BaseSignUpContent: FC = ({ const customRenderers: ComponentRendererMap = useContext(ComponentRendererContext); const {t} = useTranslation(); const {subtitle: flowSubtitle, title: flowTitle, messages: flowMessages, addMessage, clearMessages} = useFlow(); - const {meta, isInitialized: isSdkInitialized, getStorageManager} = useThunderID(); + const {meta, isInitialized: isSdkInitialized, getStorageManager, vendor} = useThunderID(); const styles: any = useStyles(theme, colorScheme); const [isLoading, setIsLoading] = useState(false); @@ -796,10 +797,14 @@ const BaseSignUpContent: FC = ({ clearMessages(); try { + const composedData: Record | undefined = data + ? composeAffixedInputs(data as Record, vendor) + : undefined; + // Filter out empty or undefined input values const filteredInputs: Record = {}; - if (data) { - Object.entries(data).forEach(([key, value]: [string, any]) => { + if (composedData) { + Object.entries(composedData).forEach(([key, value]: [string, any]) => { if (value !== null && value !== undefined && value !== '') { filteredInputs[key] = value; } @@ -978,6 +983,7 @@ const BaseSignUpContent: FC = ({ onSubmit: handleSubmit, size, variant, + vendor, }, ), [ @@ -990,6 +996,7 @@ const BaseSignUpContent: FC = ({ size, theme, variant, + vendor, inputClasses, buttonClasses, handleSubmit, diff --git a/packages/react/src/components/primitives/AffixedField/AffixedField.styles.ts b/packages/react/src/components/primitives/AffixedField/AffixedField.styles.ts new file mode 100644 index 0000000..6effd28 --- /dev/null +++ b/packages/react/src/components/primitives/AffixedField/AffixedField.styles.ts @@ -0,0 +1,160 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {css} from '@emotion/css'; +import {Theme} from '@thunderid/browser'; +import {useMemo} from 'react'; + +/** + * Creates styles for the AffixedField component. The container is a horizontal flex + * row with a prefix element (span or select) joined to a shared-border input. + */ +const useStyles = ( + theme: Theme, + colorScheme: string, + disabled: boolean, + hasError: boolean, + hasPrefix: boolean, +): Record => + useMemo(() => { + const borderColor: string = hasError ? theme.vars.colors.error.main : theme.vars.colors.border; + const focusColor: string = hasError ? theme.vars.colors.error.main : theme.vars.colors.primary.main; + const borderRadius: string = theme.vars.components?.Field?.root?.borderRadius ?? theme.vars.borderRadius.medium; + + const inputContainer: string = css` + position: relative; + display: flex; + align-items: stretch; + width: 100%; + `; + + const inputBase: string = css` + flex: 1; + min-width: 0; + padding-block: ${theme.vars.spacing.unit}; + padding-inline: calc(${theme.vars.spacing.unit} * 1.5); + border: 1px solid ${borderColor}; + font-size: ${theme.vars.typography.fontSizes.md}; + font-family: ${theme.vars.typography.fontFamily}; + color: ${theme.vars.colors.text.primary}; + background-color: ${disabled ? theme.vars.colors.background.disabled : theme.vars.colors.background.surface}; + outline: none; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; + + &::placeholder { + color: ${theme.vars.colors.text.secondary}; + opacity: 0.7; + } + + &:focus { + border-color: ${focusColor}; + box-shadow: 0 0 0 2px ${focusColor}20; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &:hover:not(:disabled) { + border-color: ${focusColor}; + } + `; + + const input: string = hasPrefix + ? css` + ${inputBase}; + border-start-start-radius: 0; + border-end-start-radius: 0; + border-start-end-radius: ${borderRadius}; + border-end-end-radius: ${borderRadius}; + border-inline-start: none; + ` + : css` + ${inputBase}; + border-radius: ${borderRadius}; + `; + + const prefixBase: string = css` + display: flex; + align-items: center; + padding-inline: calc(${theme.vars.spacing.unit} * 1.5); + border: 1px solid ${borderColor}; + border-start-start-radius: ${borderRadius}; + border-end-start-radius: ${borderRadius}; + background-color: ${theme.vars.colors.background.surface}; + color: ${theme.vars.colors.text.primary}; + font-size: ${theme.vars.typography.fontSizes.md}; + font-family: ${theme.vars.typography.fontFamily}; + white-space: nowrap; + `; + + const prefixSpan: string = css` + ${prefixBase}; + user-select: none; + color: ${theme.vars.colors.text.secondary}; + `; + + const prefixSelect: string = css` + ${prefixBase}; + cursor: pointer; + appearance: auto; + padding-inline-end: calc(${theme.vars.spacing.unit} * 1.5); + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &:focus { + outline: none; + border-color: ${focusColor}; + box-shadow: 0 0 0 2px ${focusColor}20; + z-index: 1; + } + `; + + /** + * Visually hides content while keeping it discoverable by assistive tech. Used + * to expose the fixed prefix value to screen readers via `aria-describedby` while + * the sighted-user span stays `aria-hidden` (decorative). + */ + const visuallyHidden: string = css` + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + `; + + return { + input, + inputContainer, + prefixSelect, + prefixSpan, + visuallyHidden, + }; + }, [theme, colorScheme, disabled, hasError, hasPrefix]); + +export default useStyles; diff --git a/packages/react/src/components/primitives/AffixedField/AffixedField.tsx b/packages/react/src/components/primitives/AffixedField/AffixedField.tsx new file mode 100644 index 0000000..ad728fe --- /dev/null +++ b/packages/react/src/components/primitives/AffixedField/AffixedField.tsx @@ -0,0 +1,169 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {cx} from '@emotion/css'; +import {PrefixOption, bem, withVendorCSSClassPrefix} from '@thunderid/browser'; +import {ChangeEvent, FC, InputHTMLAttributes, ReactElement, useId} from 'react'; +import useStyles from './AffixedField.styles'; +import useTheme from '../../../contexts/Theme/useTheme'; +import FormControl from '../FormControl/FormControl'; +import InputLabel from '../InputLabel/InputLabel'; + +export interface AffixedFieldProps extends Omit, 'className' | 'prefix'> { + /** Additional CSS class names applied to the outer form control. */ + className?: string; + /** Whether the field is disabled. */ + disabled?: boolean; + /** Error message to render below the field. */ + error?: string; + /** Helper text to render below the field. */ + helperText?: string; + /** Label rendered above the field. */ + label?: string; + /** + * Called when the user changes the selected prefix. Only fires when `prefixes` is an + * array with two or more entries and the user picks a different one. + */ + onPrefixChange?: (prefixValue: string) => void; + /** + * Currently selected prefix `value` (must match one of the resolved prefix entries' + * `value`). Required when `prefixes` resolves to a non-empty list. Ignored otherwise. + */ + prefixValue?: string; + /** + * Leading affix. `string` renders as a fixed span; `PrefixOption[]` renders as a fixed + * span (single entry) or dropdown (multiple entries). Empty or undefined renders a + * plain input with no leading element. + */ + prefixes?: string | PrefixOption[]; + /** Whether the field is required. */ + required?: boolean; +} + +/** + * Converts the polymorphic `prefixes` prop into a uniform list. A string is treated + * as a single fixed entry so the rest of the component can iterate a single shape. + */ +const normalizePrefixes = (prefixes: string | PrefixOption[] | undefined): PrefixOption[] => { + if (typeof prefixes === 'string') { + return prefixes.length > 0 ? [{label: prefixes, value: prefixes}] : []; + } + return prefixes ?? []; +}; + +const AffixedField: FC = ({ + label, + error, + required, + className, + disabled, + helperText, + prefixes, + prefixValue, + onPrefixChange, + type = 'text', + style = {}, + ...rest +}: AffixedFieldProps) => { + const {theme, colorScheme}: ReturnType = useTheme(); + const hasError: boolean = !!error; + const prefixList: PrefixOption[] = normalizePrefixes(prefixes); + const hasPrefix: boolean = prefixList.length > 0; + const hasFixedPrefix: boolean = prefixList.length === 1; + const styles: Record = useStyles(theme, colorScheme, disabled ?? false, hasError, hasPrefix); + // Stable id for the visually-hidden prefix announcement; only referenced when a + // fixed prefix is present, but generated unconditionally so the hook order is stable. + const prefixDescriptionId: string = `${useId()}-prefix`; + + const containerClassName: string = cx( + withVendorCSSClassPrefix(bem('affixed-field', 'container')), + styles['inputContainer'], + ); + + const inputClassName: string = cx(withVendorCSSClassPrefix(bem('affixed-field', 'input')), styles['input']); + + const renderPrefix = (): ReactElement | null => { + if (prefixList.length === 0) return null; + + if (prefixList.length === 1) { + return ( + <> + + {/* Assistive-tech-only mirror of the fixed prefix; wired to the input via aria-describedby. */} + + {prefixList[0].label} + + + ); + } + + const selectedValue: string = prefixValue ?? prefixList[0].value; + return ( + + ); + }; + + return ( + + {label && ( + + {label} + + )} +
+ {renderPrefix()} + +
+
+ ); +}; + +export default AffixedField; diff --git a/packages/react/src/components/primitives/AffixedField/index.ts b/packages/react/src/components/primitives/AffixedField/index.ts new file mode 100644 index 0000000..414ff40 --- /dev/null +++ b/packages/react/src/components/primitives/AffixedField/index.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export {default, default as AffixedField} from './AffixedField'; +export type {AffixedFieldProps} from './AffixedField'; diff --git a/packages/react/src/utils/composeAffixedInputs.ts b/packages/react/src/utils/composeAffixedInputs.ts new file mode 100644 index 0000000..769d3b1 --- /dev/null +++ b/packages/react/src/utils/composeAffixedInputs.ts @@ -0,0 +1,100 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {getVendorPrefix} from '@thunderid/browser'; + +/** + * Returns the runtime sentinel prefix used to tag "selected prefix" entries in the + * form-state map. Resolved through the vendor namespace so white-labeled deployments + * do not collide with each other or with a real field identifier. + */ +const prefixSentinel = (vendor?: string): string => `__${getVendorPrefix(vendor)}_prefix__`; + +/** + * Returns the runtime sentinel prefix used to tag "postfix" entries in the form-state + * map. See {@link prefixSentinel}. + */ +const postfixSentinel = (vendor?: string): string => `__${getVendorPrefix(vendor)}_postfix__`; + +/** + * Returns the sentinel key used to store the selected prefix `value` for the affixed + * field addressed by `identifier`. The vendor namespace controls the runtime string + * so consumers overriding `vendor` do not conflict with the SDK default. + */ +export const affixPrefixKey = (identifier: string, vendor?: string): string => `${prefixSentinel(vendor)}${identifier}`; + +/** + * Returns the sentinel key used to store the postfix `value` for the affixed field + * addressed by `identifier`. See {@link affixPrefixKey}. + */ +export const affixPostfixKey = (identifier: string, vendor?: string): string => + `${postfixSentinel(vendor)}${identifier}`; + +/** + * Rewrites `formData` (returns a new object) so that any affixed-input identifier + * tracked by matching sentinel prefix/postfix keys is replaced with its composed + * form `${prefix}${typed}${postfix}`. Sentinel keys are stripped from the result. + * + * Behaviour: + * - Prefix present, postfix present → `prefix + typed + postfix`. + * - Only prefix present → `prefix + typed`. + * - Only postfix present → `typed + postfix`. + * - Both empty / typed empty → the identifier is left untouched. + * - No sentinel keys → returns a shallow copy of `formData` unchanged. + * + * The input object is not mutated. This is component-type agnostic: any field carrying + * matching sentinel keys is composed, regardless of whether it originated from a + * TEXT_INPUT, EMAIL_INPUT, PASSWORD_INPUT, or PHONE_INPUT. + * + * @param formData - The current form values map (values indexed by field identifier). + * @param vendor - The vendor namespace configured on the SDK client, if any. Sentinel + * keys are resolved through this namespace so composition ignores unrelated keys + * written by other vendors when multiple SDK instances share a form. + */ +const composeAffixedInputs = (formData: Record, vendor?: string): Record => { + const composed: Record = {...formData}; + const prefixTag: string = prefixSentinel(vendor); + const postfixTag: string = postfixSentinel(vendor); + + for (const key of Object.keys(formData)) { + if (!key.startsWith(prefixTag) && !key.startsWith(postfixTag)) { + continue; + } + + const identifier: string = key.startsWith(prefixTag) ? key.slice(prefixTag.length) : key.slice(postfixTag.length); + + const typed: string = composed[identifier] ?? ''; + if (typed === '') { + // Preserve emptiness — required-field checks are the caller's responsibility. + delete composed[affixPrefixKey(identifier, vendor)]; + delete composed[affixPostfixKey(identifier, vendor)]; + continue; + } + + const prefix: string = composed[affixPrefixKey(identifier, vendor)] ?? ''; + const postfix: string = composed[affixPostfixKey(identifier, vendor)] ?? ''; + + composed[identifier] = `${prefix}${typed}${postfix}`; + delete composed[affixPrefixKey(identifier, vendor)]; + delete composed[affixPostfixKey(identifier, vendor)]; + } + + return composed; +}; + +export default composeAffixedInputs;