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) =>