/** * Pure, React-Native-agnostic resolution helpers for the Expo Text Input element * (NAM-1148). Kept free of any `react-native` import so they can be unit-tested * under the package's `node` jest environment (see * tests/components/elements/textInputStyle.test.ts). * * These mirror the Web sibling (sdk/web/src/components/elements/TextInput.ts) and * the Apple (NAM-1142) / Android (NAM-1143) native ports: a 4-state style * precedence (error > focused > filled > empty) and larger `television` defaults * applied only where the payload omits a value. Values are returned raw (colors as * payload strings, sizes as numbers); the component applies `parseColor`/`parseSize`. */ import type { TTextInputComponent, TTextInputType, FormFieldValidator } from '@namiml/sdk-core'; /** * TV defaults applied when the form factor is `television` and the payload omits * the value. Mirrors the Web `TV_DEFAULTS` and the native ports. */ export const TV_DEFAULTS = { borderWidth: 2, borderRadius: 6, width: 500, fontSize: 20, spacing: 16, innerPadding: 16, }; export interface ResolvedInputVisual { borderColor: string; borderWidth: number; borderRadius: number; fillColor: string; fontColor?: string; fontSize?: number | string; padding: { top: number; right: number; bottom: number; left: number }; } interface StateFlags { focused: boolean; hasError: boolean; isTelevision: boolean; } /** `null`/`undefined` numeric prop → TV fallback (or 0); otherwise `Number(value)`. */ function resolveBorderWidth(base: number | string | undefined, isTelevision: boolean): number { if (base == null) return isTelevision ? TV_DEFAULTS.borderWidth : 0; return Number(base); } /** Map the wire `type` to the validator's `TTextInputType` (email → email, else name). */ export function validatorType(component: Pick): TTextInputType { return component.type === 'email' ? 'email' : 'name'; } /** Build the field's validator from the (misspelled) `reqed` flag + author message. */ export function resolveValidator(component: TTextInputComponent): FormFieldValidator { return { type: validatorType(component), required: component.reqed === true, message: component.validationMessage, }; } /** * The label to render, or `undefined` when there is no label. Appends ` *` when the * field is required, matching the Web renderer. */ export function resolveLabelText(component: TTextInputComponent): string | undefined { if (component.labelPosition === 'none') return undefined; if (!component.labelText) return undefined; return component.reqed === true ? `${component.labelText} *` : component.labelText; } /** * Resolve the input's visual properties under the 4-state precedence * error > focused > filled > empty. Filled and empty share the resting style; the * distinction is preserved for parity with the native ports. */ export function resolveInputVisual( component: TTextInputComponent, { focused, hasError, isTelevision }: StateFlags, ): ResolvedInputVisual { let borderColor = component.borderColor || 'transparent'; let borderWidth = resolveBorderWidth(component.borderWidth, isTelevision); let borderRadius = Number(component.borderRadius ?? (isTelevision ? TV_DEFAULTS.borderRadius : 0)); let fillColor = component.fillColor || 'transparent'; if (focused) { borderColor = component.focusedBorderColor || borderColor; borderWidth = resolveBorderWidth(component.focusedBorderWidth ?? component.borderWidth, isTelevision); borderRadius = Number(component.focusedBorderRadius ?? component.borderRadius ?? borderRadius); fillColor = component.focusedFillColor || fillColor; } if (hasError) { borderColor = component.errorBorderColor || borderColor; borderWidth = resolveBorderWidth(component.errorBorderWidth ?? component.borderWidth, isTelevision); borderRadius = Number(component.errorBorderRadius ?? component.borderRadius ?? borderRadius); } const fontSize = component.fontSize ?? (isTelevision ? TV_DEFAULTS.fontSize : undefined); const fallbackPad = isTelevision ? TV_DEFAULTS.innerPadding : 0; return { borderColor, borderWidth, borderRadius, fillColor, fontColor: component.fontColor, fontSize, padding: { top: Number(component.innerTopPadding ?? fallbackPad), right: Number(component.innerRightPadding ?? fallbackPad), bottom: Number(component.innerBottomPadding ?? fallbackPad), left: Number(component.innerLeftPadding ?? fallbackPad), }, }; } /** Spacing between label / input / error, in points. TV gets a default gap. */ export function resolveSpacing(component: TTextInputComponent, isTelevision: boolean): number { const { spacing } = component; if (spacing == null) return isTelevision ? TV_DEFAULTS.spacing : 0; return Number(spacing); } /** * Whether this field should claim autofocus on mount (NAM-1529), mirroring the * button `focused` precedent (`NamiButton.tsx`): the payload's `focused` flag * only applies while the surrounding focus scope (`useFocusEnabled`) allows it, * so an off-screen/inactive step never steals focus. The mobile-vs-TV split * (keyboard raise vs. d-pad-only) lives in `useTVPreferredFocus` * (utils/tvFocus.ts) — this only decides whether autofocus applies at all. */ export function resolveAutoFocus( component: Pick, focusEnabled: boolean, ): boolean { return focusEnabled && Boolean(component.focused); }