From d25f80dff62d19b882dccaf3ac8f697c7112b8fa Mon Sep 17 00:00:00 2001 From: safaiyeh Date: Sat, 22 Aug 2026 16:40:58 -0700 Subject: [PATCH] feat(react-native): let surveys cap how far their text scales, per text role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React Native surveys render every `Text` and `TextInput` with no `maxFontSizeMultiplier`, so survey copy scales without a ceiling under the OS text-size setting. At the largest accessibility sizes an 18pt question headline renders roughly one word per line on a small phone, and a host app cannot fix it from the outside: React Native only inherits `maxFontSizeMultiplier` through nested `Text`, and these are siblings inside `View`s. `appearance.maxFontSizeMultiplier` now carries a ceiling into the survey. It takes either one number for the whole survey, or an object keyed by text role: maxFontSizeMultiplier: 1.6 maxFontSizeMultiplier: { question: 1.5, description: 1.8, ratingNumber: 1.2 } Per-role rather than one number, because one ceiling cannot serve every kind of text a survey draws. `question` is a headline that can wrap freely; `ratingNumber` sits inside a fixed-width button and has nowhere to grow; an app that already caps its own text by role wants to match those ceilings here rather than flatten them. The nine roles cover all sixteen text nodes the survey renders. Nothing changes unless it is set: an unset appearance passes `undefined`, which is React Native's "no ceiling", so every existing survey renders exactly as it did. A role omitted from the object is likewise uncapped. `0` is passed through rather than swallowed — in React Native that is a documented value meaning "no maximum", not an absent one. Two supporting changes the option needs to reach where it is read: - `PostHogSurveyProvider`'s `defaultSurveyAppearance` was typed as the shared `SurveyAppearance`, so React Native-only appearance fields could not be passed even though the provider already merges them into a `SurveyAppearanceTheme`. - `getQuestionComponent`'s intermediate props type narrowed `appearance` back to the shared type, dropping every React Native-only field; the `as any` on the call below hid it. The question components themselves already declare `SurveyAppearanceTheme`. The auto-scroll spec's react-native shim renders `Text` as a `div` and spreads unknown props, so it now strips this native-only prop alongside the others it already strips. --- .../rn-survey-max-font-size-multiplier.md | 5 + .../src/surveys/PostHogSurveyProvider.tsx | 8 +- .../src/surveys/components/BottomSection.tsx | 9 +- .../components/ConfirmationMessage.tsx | 15 +- .../src/surveys/components/IntroMessage.tsx | 17 ++- .../src/surveys/components/QuestionHeader.tsx | 13 +- .../src/surveys/components/QuestionTypes.tsx | 15 +- .../src/surveys/components/Surveys.tsx | 14 +- .../react-native/src/surveys/surveys-utils.ts | 73 ++++++++++ .../test/surveyMaxFontSizeMultiplier.spec.tsx | 129 ++++++++++++++++++ .../test/surveys-autoScroll.spec.tsx | 1 + 11 files changed, 278 insertions(+), 21 deletions(-) create mode 100644 .changeset/rn-survey-max-font-size-multiplier.md create mode 100644 packages/react-native/test/surveyMaxFontSizeMultiplier.spec.tsx diff --git a/.changeset/rn-survey-max-font-size-multiplier.md b/.changeset/rn-survey-max-font-size-multiplier.md new file mode 100644 index 0000000000..83fe342a07 --- /dev/null +++ b/.changeset/rn-survey-max-font-size-multiplier.md @@ -0,0 +1,5 @@ +--- +'posthog-react-native': minor +--- + +feat(react-native): let surveys cap how far their text scales with the OS text-size setting, per text role — `appearance.maxFontSizeMultiplier` takes one number for the whole survey or an object keyed by role (`question`, `description`, `header`, `choice`, `input`, `button`, `ratingLabel`, `ratingNumber`, `validationHint`). Unset, text scales without a ceiling exactly as before. diff --git a/packages/react-native/src/surveys/PostHogSurveyProvider.tsx b/packages/react-native/src/surveys/PostHogSurveyProvider.tsx index 332729b0e8..51d5e87b56 100644 --- a/packages/react-native/src/surveys/PostHogSurveyProvider.tsx +++ b/packages/react-native/src/surveys/PostHogSurveyProvider.tsx @@ -7,7 +7,7 @@ import { useSurveyStorage } from './useSurveyStorage' import { useActivatedSurveys } from './useActivatedSurveys' import { SurveyModal } from './components/SurveyModal' import { defaultSurveyAppearance, getContrastingTextColor, SurveyAppearanceTheme } from './surveys-utils' -import { Survey, SurveyAppearance, SurveyType, type SurveyResponses } from '@posthog/core' +import { Survey, SurveyType, type SurveyResponses } from '@posthog/core' import { usePostHog } from '../hooks/usePostHog' import { useFeatureFlags } from '../hooks/useFeatureFlags' import { PostHog } from '../posthog-rn' @@ -73,8 +73,12 @@ export type PostHogSurveyProviderProps = { /** * The default appearance for surveys when not specified in PostHog. + * + * Accepts the React Native-only appearance fields as well (e.g. + * `maxFontSizeMultiplier`) — they are merged into the same theme object the + * survey components read, and PostHog never sends them down. */ - defaultSurveyAppearance?: SurveyAppearance + defaultSurveyAppearance?: Partial /** * If true, PosHog appearance will be ignored and defaultSurveyAppearance is always used. diff --git a/packages/react-native/src/surveys/components/BottomSection.tsx b/packages/react-native/src/surveys/components/BottomSection.tsx index b0c6d0f559..6fa93c6c14 100644 --- a/packages/react-native/src/surveys/components/BottomSection.tsx +++ b/packages/react-native/src/surveys/components/BottomSection.tsx @@ -2,7 +2,7 @@ import React from 'react' import { Linking, Text, TouchableOpacity, View } from 'react-native' import { createSafeStyleSheet } from '../safeStyleSheet' -import { SurveyAppearanceTheme } from '../surveys-utils' +import { getMaxFontSizeMultiplier, SurveyAppearanceTheme } from '../surveys-utils' export function BottomSection({ text, @@ -43,7 +43,12 @@ export function BottomSection({ } }} > - {text} + + {text} + ) diff --git a/packages/react-native/src/surveys/components/ConfirmationMessage.tsx b/packages/react-native/src/surveys/components/ConfirmationMessage.tsx index e739c1b41c..56051fc5c3 100644 --- a/packages/react-native/src/surveys/components/ConfirmationMessage.tsx +++ b/packages/react-native/src/surveys/components/ConfirmationMessage.tsx @@ -5,6 +5,7 @@ import { createSafeStyleSheet } from '../safeStyleSheet' import { defaultDescriptionOpacity, getContrastingTextColor, + getMaxFontSizeMultiplier, shouldRenderDescription, SurveyAppearanceTheme, } from '../surveys-utils' @@ -33,9 +34,19 @@ export function ConfirmationMessage({ return ( - {header} + + {header} + {shouldRenderDescription(description, contentType) && ( - {description} + + {description} + )} {isModal && ( diff --git a/packages/react-native/src/surveys/components/IntroMessage.tsx b/packages/react-native/src/surveys/components/IntroMessage.tsx index 4f4cb627d6..80c629e5bb 100644 --- a/packages/react-native/src/surveys/components/IntroMessage.tsx +++ b/packages/react-native/src/surveys/components/IntroMessage.tsx @@ -5,6 +5,7 @@ import { createSafeStyleSheet } from '../safeStyleSheet' import { defaultDescriptionOpacity, getContrastingTextColor, + getMaxFontSizeMultiplier, shouldRenderDescription, SurveyAppearanceTheme, } from '../surveys-utils' @@ -29,9 +30,21 @@ export function IntroMessage({ return ( - {header ? {header} : null} + {header ? ( + + {header} + + ) : null} {shouldRenderDescription(description, contentType) && ( - {description} + + {description} + )} - {question} + + {question} + {shouldRenderDescription(description, descriptionContentType) && ( - + {description} )} diff --git a/packages/react-native/src/surveys/components/QuestionTypes.tsx b/packages/react-native/src/surveys/components/QuestionTypes.tsx index a151b822a3..617ffec04a 100644 --- a/packages/react-native/src/surveys/components/QuestionTypes.tsx +++ b/packages/react-native/src/surveys/components/QuestionTypes.tsx @@ -15,6 +15,7 @@ import { import { defaultRatingLabelOpacity, getContrastingTextColor, + getMaxFontSizeMultiplier, getDisplayOrderChoices, SurveyAppearanceTheme, } from '../surveys-utils' @@ -121,6 +122,7 @@ export function OpenTextQuestion({ /> {requirementsHint && ( setActiveNumber(num)} > - {num} + + {num} + ) } @@ -392,7 +399,10 @@ export function MultipleChoiceQuestion({ }} > - + {choice} {isOpenChoice ? ':' : ''} @@ -400,6 +410,7 @@ export function MultipleChoiceQuestion({ {isOpenChoice && ( { setOpenEndedInput(userValue) diff --git a/packages/react-native/src/surveys/components/Surveys.tsx b/packages/react-native/src/surveys/components/Surveys.tsx index 9e2baa1534..461e23eeb9 100644 --- a/packages/react-native/src/surveys/components/Surveys.tsx +++ b/packages/react-native/src/surveys/components/Surveys.tsx @@ -2,14 +2,7 @@ import React, { useMemo, useState } from 'react' import { StyleProp, ViewStyle } from 'react-native' import { getDisplayOrderQuestions, getNextSurveyStep, SurveyAppearanceTheme } from '../surveys-utils' -import { - Survey, - SurveyAppearance, - SurveyQuestion, - type SurveyResponses, - maybeAdd, - SurveyQuestionBranchingType, -} from '@posthog/core' +import { Survey, SurveyQuestion, type SurveyResponses, maybeAdd, SurveyQuestionBranchingType } from '@posthog/core' import { buildSurveyResponseProperties, getSurveyInteractionProperty, @@ -140,7 +133,10 @@ export function Questions({ type GetQuestionComponentProps = { question: SurveyQuestion - appearance: SurveyAppearance + // The question components each declare `SurveyAppearanceTheme`; typing this + // intermediate as the shared `SurveyAppearance` dropped every React + // Native-only field, which the `as any` below then hid. + appearance: SurveyAppearanceTheme styleOverrides?: StyleProp onSubmit: (res: string | string[] | number | null) => void } diff --git a/packages/react-native/src/surveys/surveys-utils.ts b/packages/react-native/src/surveys/surveys-utils.ts index d33f7f793f..5fdcfe9bb7 100644 --- a/packages/react-native/src/surveys/surveys-utils.ts +++ b/packages/react-native/src/surveys/surveys-utils.ts @@ -121,12 +121,85 @@ export const defaultDescriptionOpacity = 0.8 export const defaultRatingLabelOpacity = 0.7 // textColor and inputTextColor are optional overrides (auto-calculated if not provided) +/** + * The distinct kinds of text a survey renders, each at its own base size. They + * are separate because one ceiling cannot serve all of them: `question` is an + * 18pt headline that can wrap freely, while `ratingNumber` sits inside a + * fixed-width button and has nowhere to grow. + */ +export type SurveyTextRole = + /** The question headline. */ + | 'question' + /** Body copy under a question, an intro screen, or the thank-you screen. */ + | 'description' + /** The intro-screen and thank-you-screen headers. */ + | 'header' + /** A choice label in a single- or multiple-choice question. */ + | 'choice' + /** Text the user types - the open-text answer and the open-choice field. */ + | 'input' + /** The submit / next button label. */ + | 'button' + /** The lower- and upper-bound labels under a rating scale. */ + | 'ratingLabel' + /** The numeral inside a rating button, which cannot grow past the button. */ + | 'ratingNumber' + /** The validation hint under an open-text answer. */ + | 'validationHint' + export type SurveyAppearanceTheme = Omit< Required, 'widgetSelector' | 'widgetType' | 'widgetColor' | 'widgetLabel' | 'shuffleQuestions' | 'textColor' | 'inputTextColor' > & { textColor?: string inputTextColor?: string + /** + * Caps how far survey text may grow under the OS text-size setting, as a + * multiple of its base size - React Native's `maxFontSizeMultiplier`, applied + * to every `Text` and `TextInput` the survey renders. + * + * Pass a number to cap every role at once, or an object to cap them + * separately: a survey headline can usually take more scaling than a numeral + * inside a rating button, and a host app that already caps its own text by + * role will want to match those ceilings here. + * + * ```ts + * maxFontSizeMultiplier: 1.6 + * // or + * maxFontSizeMultiplier: { question: 1.5, description: 1.8, ratingNumber: 1.2 } + * ``` + * + * Roles left out of the object are uncapped, as they are today. + * + * Leave it unset (the default) and survey text scales without a ceiling. That + * is what the OS asks for, but not always what a fixed-size survey card can + * hold - at the largest accessibility sizes an unbounded 18pt headline renders + * roughly one word per line. + * + * React Native only inherits this prop through nested `Text`, so a host app + * cannot apply it from the outside - it has to come in here. + * + * @default undefined (no ceiling) + */ + maxFontSizeMultiplier?: number | Partial> +} + +/** + * The ceiling for one role, from either form of `maxFontSizeMultiplier`. + * + * Returns `undefined` when nothing is configured for that role, which is also + * React Native's "no ceiling" - so an unset appearance renders exactly as it + * did before this option existed. + */ +export function getMaxFontSizeMultiplier( + appearance: Pick, + role: SurveyTextRole +): number | undefined { + const configured = appearance.maxFontSizeMultiplier + if (typeof configured === 'number') { + return configured + } + return configured?.[role] } export const defaultSurveyAppearance: SurveyAppearanceTheme = { backgroundColor: defaultBackgroundColor, diff --git a/packages/react-native/test/surveyMaxFontSizeMultiplier.spec.tsx b/packages/react-native/test/surveyMaxFontSizeMultiplier.spec.tsx new file mode 100644 index 0000000000..a562195ba6 --- /dev/null +++ b/packages/react-native/test/surveyMaxFontSizeMultiplier.spec.tsx @@ -0,0 +1,129 @@ +/** @jest-environment jsdom */ +import React from 'react' +import { render, cleanup } from '@testing-library/react' + +// Records the props every Text/TextInput is rendered with, so a test can assert +// which ceiling reached which node. Same minimal react-native shim as +// SurveyModal.spec — jest-expo's full preset pulls in TurboModule code that +// explodes under jsdom. +const renderedTextProps: { children: unknown; maxFontSizeMultiplier: number | undefined }[] = [] + +jest.mock('react-native', () => { + const RealReact = jest.requireActual('react') + const Box = RealReact.forwardRef(({ children, testID, ...rest }: any, ref: any) => + RealReact.createElement('div', { ref, 'data-testid': testID }, children) + ) + const RecordingText = RealReact.forwardRef(({ children, maxFontSizeMultiplier, testID }: any, ref: any) => { + renderedTextProps.push({ children, maxFontSizeMultiplier }) + return RealReact.createElement('div', { ref, 'data-testid': testID }, children) + }) + return { + View: Box, + Modal: Box, + KeyboardAvoidingView: Box, + Pressable: Box, + TouchableOpacity: Box, + Text: RecordingText, + TextInput: RecordingText, + Linking: { canOpenURL: jest.fn(), openURL: jest.fn() }, + Platform: { OS: 'ios', select: (o: any) => o.ios ?? o.default }, + StyleSheet: { create: (s: any) => s, flatten: (s: any) => s, absoluteFill: {} }, + useWindowDimensions: () => ({ width: 375, height: 800 }), + } +}) + +import { BottomSection } from '../src/surveys/components/BottomSection' +import { QuestionHeader } from '../src/surveys/components/QuestionHeader' +import { defaultSurveyAppearance, getMaxFontSizeMultiplier, SurveyAppearanceTheme } from '../src/surveys/surveys-utils' + +const capOf = (text: string): number | undefined => + renderedTextProps.find((entry) => entry.children === text)?.maxFontSizeMultiplier + +beforeEach(() => { + renderedTextProps.length = 0 +}) + +afterEach(cleanup) + +describe('getMaxFontSizeMultiplier', () => { + it('returns undefined when nothing is configured, so text keeps scaling as it does today', () => { + expect(getMaxFontSizeMultiplier({}, 'question')).toBeUndefined() + expect(getMaxFontSizeMultiplier({ maxFontSizeMultiplier: undefined }, 'question')).toBeUndefined() + }) + + it('applies a plain number to every role', () => { + const appearance = { maxFontSizeMultiplier: 1.6 } + expect(getMaxFontSizeMultiplier(appearance, 'question')).toBe(1.6) + expect(getMaxFontSizeMultiplier(appearance, 'ratingNumber')).toBe(1.6) + expect(getMaxFontSizeMultiplier(appearance, 'input')).toBe(1.6) + }) + + it('applies per-role ceilings independently', () => { + const appearance = { maxFontSizeMultiplier: { question: 1.5, ratingNumber: 1.2 } } + expect(getMaxFontSizeMultiplier(appearance, 'question')).toBe(1.5) + expect(getMaxFontSizeMultiplier(appearance, 'ratingNumber')).toBe(1.2) + }) + + it('leaves roles the object omits uncapped', () => { + const appearance = { maxFontSizeMultiplier: { question: 1.5 } } + expect(getMaxFontSizeMultiplier(appearance, 'description')).toBeUndefined() + }) + + it('passes 0 through instead of swallowing it', () => { + // In React Native 0 means "no maximum" — a distinct, documented value, not + // an absent one. A truthiness check here would turn it into `undefined`, + // which happens to render the same but stops meaning what the caller said. + expect(getMaxFontSizeMultiplier({ maxFontSizeMultiplier: 0 }, 'question')).toBe(0) + expect(getMaxFontSizeMultiplier({ maxFontSizeMultiplier: { question: 0 } }, 'question')).toBe(0) + }) +}) + +describe('survey text ceilings', () => { + const base: SurveyAppearanceTheme = { ...defaultSurveyAppearance } + + it('passes no ceiling when the appearance does not configure one', () => { + render() + + expect(renderedTextProps).toHaveLength(2) + expect(renderedTextProps.every((entry) => entry.maxFontSizeMultiplier === undefined)).toBe(true) + }) + + it('gives the question headline and its description their own ceilings', () => { + render( + + ) + + expect(capOf('What went wrong?')).toBe(1.4) + expect(capOf('Tell us more')).toBe(1.9) + }) + + it('caps the submit button independently of the question', () => { + render( + {}} + /> + ) + + expect(capOf('Send feedback')).toBe(1.3) + }) + + it('applies a single number to every role it renders', () => { + render( + + ) + + expect(capOf('What went wrong?')).toBe(1.6) + expect(capOf('Tell us more')).toBe(1.6) + }) +}) diff --git a/packages/react-native/test/surveys-autoScroll.spec.tsx b/packages/react-native/test/surveys-autoScroll.spec.tsx index c3c7aab177..6802980711 100644 --- a/packages/react-native/test/surveys-autoScroll.spec.tsx +++ b/packages/react-native/test/surveys-autoScroll.spec.tsx @@ -23,6 +23,7 @@ jest.mock('react-native', () => { delete domProps.placeholderTextColor delete domProps.onChangeText delete domProps.underlineColorAndroid + delete domProps.maxFontSizeMultiplier return domProps } const Box = RealReact.forwardRef(({ children, testID, ...rest }: any, ref: any) =>