@namiml/expo-sdk 3.4.7-dev.202607230540 → 3.4.7-dev.202607231604
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +212 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +213 -15
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/components/PaywallScreen.tsx +27 -12
- package/src/components/elements/A11yLiveRegion.kepler.tsx +28 -0
- package/src/components/elements/A11yLiveRegion.tsx +24 -0
- package/src/components/elements/NamiButton.tsx +10 -2
- package/src/components/elements/NamiTextInput.tsx +28 -3
- package/src/components/elements/textInputStyle.ts +15 -0
- package/src/context/AnnouncementContext.tsx +103 -0
- package/src/utils/a11yAnnouncement.ts +90 -0
- package/src/utils/announcementMachine.ts +63 -0
package/dist/index.cjs
CHANGED
|
@@ -3081,14 +3081,14 @@ const NamiContentContainer = ({ component, scaleFactor, onClose, }) => {
|
|
|
3081
3081
|
}, [component.verticalAlignment, component.horizontalAlignment, flexDir]);
|
|
3082
3082
|
const renderedChildren = require$$0.useMemo(() => {
|
|
3083
3083
|
return component.components?.map((comp, i) => (jsxRuntimeExports.jsx(reactNative.View, { style: [
|
|
3084
|
-
styles$
|
|
3084
|
+
styles$6.fullWidth,
|
|
3085
3085
|
i === 0
|
|
3086
3086
|
? null
|
|
3087
3087
|
: childSpacingStyle(i, { spacing: component.spacing, direction }, scaleFactor),
|
|
3088
3088
|
], children: jsxRuntimeExports.jsx(TemplateRenderer, { component: comp, scaleFactor: scaleFactor, onClose: onClose, parentDirection: direction }) }, comp.id ?? i)));
|
|
3089
3089
|
}, [component.components, component.spacing, direction, scaleFactor, onClose]);
|
|
3090
3090
|
const innerContentStyle = require$$0.useMemo(() => [
|
|
3091
|
-
styles$
|
|
3091
|
+
styles$6.inner,
|
|
3092
3092
|
{
|
|
3093
3093
|
flexDirection: flexDir,
|
|
3094
3094
|
},
|
|
@@ -3096,11 +3096,11 @@ const NamiContentContainer = ({ component, scaleFactor, onClose, }) => {
|
|
|
3096
3096
|
contentAlignment,
|
|
3097
3097
|
], [flexDir, inner, contentAlignment]);
|
|
3098
3098
|
if (!scrollEnabled) {
|
|
3099
|
-
return (jsxRuntimeExports.jsx(reactNative.View, { style: [styles$
|
|
3099
|
+
return (jsxRuntimeExports.jsx(reactNative.View, { style: [styles$6.outer, outer], children: jsxRuntimeExports.jsx(reactNative.View, { style: [styles$6.scroll, innerContentStyle], children: renderedChildren }) }));
|
|
3100
3100
|
}
|
|
3101
|
-
return (jsxRuntimeExports.jsx(reactNative.View, { style: [styles$
|
|
3101
|
+
return (jsxRuntimeExports.jsx(reactNative.View, { style: [styles$6.outer, outer], children: jsxRuntimeExports.jsx(reactNative.ScrollView, { style: styles$6.scroll, contentContainerStyle: innerContentStyle, showsVerticalScrollIndicator: false, scrollEnabled: true, children: renderedChildren }) }));
|
|
3102
3102
|
};
|
|
3103
|
-
const styles$
|
|
3103
|
+
const styles$6 = reactNative.StyleSheet.create({
|
|
3104
3104
|
outer: {
|
|
3105
3105
|
flex: 1,
|
|
3106
3106
|
minHeight: 0,
|
|
@@ -3142,9 +3142,9 @@ const NamiBackgroundContainer = ({ component, scaleFactor, onClose }) => {
|
|
|
3142
3142
|
return (jsxRuntimeExports.jsx(TemplateRenderer, { component: spacedComponent, scaleFactor: scaleFactor, onClose: onClose, parentDirection: direction }, comp.id ?? i));
|
|
3143
3143
|
}) }));
|
|
3144
3144
|
if (fillImageUrl) {
|
|
3145
|
-
return (jsxRuntimeExports.jsx(reactNative.ImageBackground, { source: { uri: fillImageUrl }, resizeMode: 'cover', style: [containerStyle, styles$
|
|
3145
|
+
return (jsxRuntimeExports.jsx(reactNative.ImageBackground, { source: { uri: fillImageUrl }, resizeMode: 'cover', style: [containerStyle, styles$5.background, clipStyle], children: content }));
|
|
3146
3146
|
}
|
|
3147
|
-
return (jsxRuntimeExports.jsx(reactNative.View, { style: [containerStyle, bgColor ? { backgroundColor: bgColor } : null, styles$
|
|
3147
|
+
return (jsxRuntimeExports.jsx(reactNative.View, { style: [containerStyle, bgColor ? { backgroundColor: bgColor } : null, styles$5.background, clipStyle], children: content }));
|
|
3148
3148
|
};
|
|
3149
3149
|
function mergeRawMarginValue(existing, spacing) {
|
|
3150
3150
|
if (existing == null)
|
|
@@ -3170,7 +3170,7 @@ function toNumericMargin(value) {
|
|
|
3170
3170
|
const parsed = Number(trimmed);
|
|
3171
3171
|
return Number.isFinite(parsed) ? parsed : null;
|
|
3172
3172
|
}
|
|
3173
|
-
const styles$
|
|
3173
|
+
const styles$5 = reactNative.StyleSheet.create({
|
|
3174
3174
|
background: {
|
|
3175
3175
|
...reactNative.StyleSheet.absoluteFillObject,
|
|
3176
3176
|
},
|
|
@@ -3185,9 +3185,9 @@ const NamiHeader = ({ components, scaleFactor, onClose }) => {
|
|
|
3185
3185
|
? components
|
|
3186
3186
|
: components?.components ?? [components];
|
|
3187
3187
|
const renderedList = require$$0.useMemo(() => list.map((comp, i) => (jsxRuntimeExports.jsx(TemplateRenderer, { component: comp, scaleFactor: scaleFactor, onClose: onClose }, comp.id ?? i))), [list, scaleFactor, onClose]);
|
|
3188
|
-
return (jsxRuntimeExports.jsx(reactNative.View, { style: [styles$
|
|
3188
|
+
return (jsxRuntimeExports.jsx(reactNative.View, { style: [styles$4.header, { marginTop }], children: renderedList }));
|
|
3189
3189
|
};
|
|
3190
|
-
const styles$
|
|
3190
|
+
const styles$4 = reactNative.StyleSheet.create({
|
|
3191
3191
|
header: {
|
|
3192
3192
|
display: 'flex',
|
|
3193
3193
|
position: 'relative',
|
|
@@ -3202,9 +3202,9 @@ const NamiFooter = ({ components, scaleFactor, onClose }) => {
|
|
|
3202
3202
|
const list = Array.isArray(components)
|
|
3203
3203
|
? components
|
|
3204
3204
|
: components?.components ?? [components];
|
|
3205
|
-
return (jsxRuntimeExports.jsx(reactNative.View, { style: styles$
|
|
3205
|
+
return (jsxRuntimeExports.jsx(reactNative.View, { style: styles$3.footer, children: list.map((comp, i) => (jsxRuntimeExports.jsx(TemplateRenderer, { component: comp, scaleFactor: scaleFactor, onClose: onClose }, comp.id ?? i))) }));
|
|
3206
3206
|
};
|
|
3207
|
-
const styles$
|
|
3207
|
+
const styles$3 = reactNative.StyleSheet.create({
|
|
3208
3208
|
footer: {
|
|
3209
3209
|
position: 'absolute',
|
|
3210
3210
|
bottom: 0,
|
|
@@ -3215,6 +3215,196 @@ const styles$2 = reactNative.StyleSheet.create({
|
|
|
3215
3215
|
},
|
|
3216
3216
|
});
|
|
3217
3217
|
|
|
3218
|
+
/**
|
|
3219
|
+
* NAM-403: full-page screenreader announcement helpers for the Vega/Kepler
|
|
3220
|
+
* (and shared Expo) renderer. RN-free so the jest node environment can unit-test
|
|
3221
|
+
* it (mirrors imageAccessibility.ts). See the TV Full-Page Announcement contract.
|
|
3222
|
+
*/
|
|
3223
|
+
// Reserved action functions whose buttons never trigger a full-page announcement
|
|
3224
|
+
// and always speak only their own label. Source of truth: utils/actionHandler.ts
|
|
3225
|
+
// (duplicated here to keep this module RN-free and node-testable).
|
|
3226
|
+
const TEXT_TYPES = new Set(['text', 'text-list']);
|
|
3227
|
+
// Subtrees never descended into during the page-text walk. Button labels are
|
|
3228
|
+
// announced on their own focus; media/decoration carry no announce-worthy text.
|
|
3229
|
+
const SKIP_SUBTREE_TYPES = new Set(['button', 'image', 'videoUrl', 'spacer', 'symbol']);
|
|
3230
|
+
function childrenOf(node) {
|
|
3231
|
+
if (Array.isArray(node))
|
|
3232
|
+
return node;
|
|
3233
|
+
if (node && Array.isArray(node.components))
|
|
3234
|
+
return node.components;
|
|
3235
|
+
return [];
|
|
3236
|
+
}
|
|
3237
|
+
function collectNode(node, resolve, out) {
|
|
3238
|
+
if (!node || typeof node !== 'object')
|
|
3239
|
+
return;
|
|
3240
|
+
if (Array.isArray(node)) {
|
|
3241
|
+
for (const child of node)
|
|
3242
|
+
collectNode(child, resolve, out);
|
|
3243
|
+
return;
|
|
3244
|
+
}
|
|
3245
|
+
if (node.hidden === true)
|
|
3246
|
+
return;
|
|
3247
|
+
const type = node.component;
|
|
3248
|
+
if (type && TEXT_TYPES.has(type)) {
|
|
3249
|
+
if (type === 'text-list' && Array.isArray(node.texts)) {
|
|
3250
|
+
for (const item of node.texts) {
|
|
3251
|
+
const resolved = resolve(typeof item === 'string' ? item : String(item ?? '')).trim();
|
|
3252
|
+
if (resolved)
|
|
3253
|
+
out.push(resolved);
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
3256
|
+
else {
|
|
3257
|
+
const raw = node.text ?? node.title;
|
|
3258
|
+
const resolved = resolve(typeof raw === 'string' ? raw : raw == null ? '' : String(raw)).trim();
|
|
3259
|
+
if (resolved)
|
|
3260
|
+
out.push(resolved);
|
|
3261
|
+
}
|
|
3262
|
+
return; // text nodes have no announce-worthy descendants
|
|
3263
|
+
}
|
|
3264
|
+
if (type && SKIP_SUBTREE_TYPES.has(type))
|
|
3265
|
+
return;
|
|
3266
|
+
for (const child of childrenOf(node))
|
|
3267
|
+
collectNode(child, resolve, out);
|
|
3268
|
+
}
|
|
3269
|
+
/**
|
|
3270
|
+
* Walk a page (header -> contentContainer -> footer) in source order and join the
|
|
3271
|
+
* resolved text of every `text` / `text-list` node. Skips images, media, spacers,
|
|
3272
|
+
* symbols, button subtrees, and any node with `hidden: true` (and its children).
|
|
3273
|
+
*/
|
|
3274
|
+
function collectPageAnnouncementText(page, resolve) {
|
|
3275
|
+
const out = [];
|
|
3276
|
+
collectNode(page?.header, resolve, out);
|
|
3277
|
+
collectNode(page?.contentContainer, resolve, out);
|
|
3278
|
+
collectNode(page?.footer, resolve, out);
|
|
3279
|
+
return out.join('. ');
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3282
|
+
function createAnnouncementMachine() {
|
|
3283
|
+
let pageAnnounced = false;
|
|
3284
|
+
let deferred = null;
|
|
3285
|
+
return {
|
|
3286
|
+
onFocus({ kind, ownLabel, videoPlaying, buildComposite }) {
|
|
3287
|
+
if (videoPlaying) {
|
|
3288
|
+
// Defer: the button is hidden from a11y during playback, so nothing is
|
|
3289
|
+
// spoken now; the flush on video end says the composite (first primary
|
|
3290
|
+
// focus) or the focused button's own label.
|
|
3291
|
+
deferred = kind === 'primary' && !pageAnnounced ? buildComposite() : ownLabel;
|
|
3292
|
+
if (kind === 'primary')
|
|
3293
|
+
pageAnnounced = true;
|
|
3294
|
+
return null;
|
|
3295
|
+
}
|
|
3296
|
+
if (kind === 'auxiliary')
|
|
3297
|
+
return null; // natural focus read handles it
|
|
3298
|
+
if (pageAnnounced)
|
|
3299
|
+
return null; // subsequent focus -> natural read
|
|
3300
|
+
pageAnnounced = true;
|
|
3301
|
+
return buildComposite();
|
|
3302
|
+
},
|
|
3303
|
+
onVideoStopped() {
|
|
3304
|
+
if (deferred == null)
|
|
3305
|
+
return null;
|
|
3306
|
+
const text = deferred;
|
|
3307
|
+
deferred = null;
|
|
3308
|
+
return text;
|
|
3309
|
+
},
|
|
3310
|
+
reset() {
|
|
3311
|
+
pageAnnounced = false;
|
|
3312
|
+
deferred = null;
|
|
3313
|
+
},
|
|
3314
|
+
};
|
|
3315
|
+
}
|
|
3316
|
+
|
|
3317
|
+
let state = {
|
|
3318
|
+
playing: false,
|
|
3319
|
+
muted: false,
|
|
3320
|
+
};
|
|
3321
|
+
const listeners = new Set();
|
|
3322
|
+
function getVideoControlState() {
|
|
3323
|
+
return state;
|
|
3324
|
+
}
|
|
3325
|
+
function subscribeVideoControls(listener) {
|
|
3326
|
+
listeners.add(listener);
|
|
3327
|
+
listener(state);
|
|
3328
|
+
return () => listeners.delete(listener);
|
|
3329
|
+
}
|
|
3330
|
+
|
|
3331
|
+
const AnnouncementCtx = require$$0.createContext(null);
|
|
3332
|
+
// Re-announcing identical text will not fire the live region again; append an
|
|
3333
|
+
// alternating zero-width space (U+200B) so each announce is a distinct text value.
|
|
3334
|
+
const ZERO_WIDTH_SPACE = "\u200B";
|
|
3335
|
+
let toggle = false;
|
|
3336
|
+
function distinct(text) {
|
|
3337
|
+
toggle = !toggle;
|
|
3338
|
+
return toggle ? text : `${text}${ZERO_WIDTH_SPACE}`;
|
|
3339
|
+
}
|
|
3340
|
+
/**
|
|
3341
|
+
* NAM-403: holds per-page announcement state and drives the assertive live region.
|
|
3342
|
+
* Pure decision logic lives in announcementMachine; this provider wires it to
|
|
3343
|
+
* React state, the video-controls subscription, and smart-text resolution.
|
|
3344
|
+
* Reset per page via the `page` dependency.
|
|
3345
|
+
*/
|
|
3346
|
+
const AnnouncementProvider = ({ page, children, }) => {
|
|
3347
|
+
const ctx = usePaywallContext();
|
|
3348
|
+
const [liveRegionText, setLiveRegionText] = require$$0.useState('');
|
|
3349
|
+
const [isVideoPlaying, setIsVideoPlaying] = require$$0.useState(() => getVideoControlState().playing);
|
|
3350
|
+
const machineRef = require$$0.useRef(createAnnouncementMachine());
|
|
3351
|
+
const prevPlayingRef = require$$0.useRef(getVideoControlState().playing);
|
|
3352
|
+
const buildComposite = require$$0.useCallback((ownLabel) => {
|
|
3353
|
+
const replacements = buildSmartTextReplacements(ctx.state, ctx.flow, undefined);
|
|
3354
|
+
const resolve = (v) => {
|
|
3355
|
+
const out = interpolateSmartText(v ?? '', replacements);
|
|
3356
|
+
return out == null ? '' : String(out);
|
|
3357
|
+
};
|
|
3358
|
+
const pageText = collectPageAnnouncementText(page, resolve);
|
|
3359
|
+
return pageText ? `${pageText}. ${ownLabel}` : ownLabel;
|
|
3360
|
+
}, [ctx.state, ctx.flow, page]);
|
|
3361
|
+
const announceOnFocus = require$$0.useCallback((kind, ownLabel) => {
|
|
3362
|
+
const text = machineRef.current.onFocus({
|
|
3363
|
+
kind,
|
|
3364
|
+
ownLabel,
|
|
3365
|
+
videoPlaying: getVideoControlState().playing,
|
|
3366
|
+
buildComposite: () => buildComposite(ownLabel),
|
|
3367
|
+
});
|
|
3368
|
+
if (text != null)
|
|
3369
|
+
setLiveRegionText(distinct(text));
|
|
3370
|
+
}, [buildComposite]);
|
|
3371
|
+
// Track video playback; flush any deferred announcement when it ends.
|
|
3372
|
+
require$$0.useEffect(() => subscribeVideoControls((state) => {
|
|
3373
|
+
setIsVideoPlaying(state.playing);
|
|
3374
|
+
if (prevPlayingRef.current && !state.playing) {
|
|
3375
|
+
const text = machineRef.current.onVideoStopped();
|
|
3376
|
+
if (text != null)
|
|
3377
|
+
setLiveRegionText(distinct(text));
|
|
3378
|
+
}
|
|
3379
|
+
prevPlayingRef.current = state.playing;
|
|
3380
|
+
}), []);
|
|
3381
|
+
// Reset per page.
|
|
3382
|
+
require$$0.useEffect(() => {
|
|
3383
|
+
machineRef.current.reset();
|
|
3384
|
+
setLiveRegionText('');
|
|
3385
|
+
}, [page]);
|
|
3386
|
+
const value = require$$0.useMemo(() => ({ isVideoPlaying, liveRegionText, announceOnFocus }), [isVideoPlaying, liveRegionText, announceOnFocus]);
|
|
3387
|
+
return jsxRuntimeExports.jsx(AnnouncementCtx.Provider, { value: value, children: children });
|
|
3388
|
+
};
|
|
3389
|
+
function useAnnouncer() {
|
|
3390
|
+
const ctx = require$$0.useContext(AnnouncementCtx);
|
|
3391
|
+
if (!ctx)
|
|
3392
|
+
throw new Error('useAnnouncer must be used within an AnnouncementProvider');
|
|
3393
|
+
return ctx;
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
/**
|
|
3397
|
+
* NAM-403: visually-hidden assertive live region used to speak the full-page
|
|
3398
|
+
* composite announcement. Default (RN) path uses accessibilityLiveRegion, which
|
|
3399
|
+
* TalkBack/VoiceView honor on focus-independent text changes. The Kepler override
|
|
3400
|
+
* (A11yLiveRegion.kepler.tsx) uses Amazon's preferred aria-live prop. Empty text
|
|
3401
|
+
* renders nothing to speak.
|
|
3402
|
+
*/
|
|
3403
|
+
const A11yLiveRegion = ({ text }) => (jsxRuntimeExports.jsx(reactNative.Text, { accessible: !!text, accessibilityLiveRegion: "assertive", style: styles$2.hidden, allowFontScaling: false, children: text }));
|
|
3404
|
+
const styles$2 = reactNative.StyleSheet.create({
|
|
3405
|
+
hidden: { position: 'absolute', width: 1, height: 1, overflow: 'hidden', left: -9999, opacity: 0 },
|
|
3406
|
+
});
|
|
3407
|
+
|
|
3218
3408
|
function buildImpressionPayload(impression) {
|
|
3219
3409
|
const payload = { ...impression };
|
|
3220
3410
|
const sessionId = sdkCore.storageService.getSessionId();
|
|
@@ -3387,8 +3577,16 @@ const PaywallScreen = ({ paywall, onClose, onCommitted, holdInteractionUntilFocu
|
|
|
3387
3577
|
// its onPress fires only for taps on non-interactive/empty areas. TV has no
|
|
3388
3578
|
// keyboard, so it renders the content without the Pressable.
|
|
3389
3579
|
const isTelevision = ctx.state.formFactor === 'television';
|
|
3390
|
-
const pageContent = (jsxRuntimeExports.jsxs(PageFocusWrapper, { ...pageFocusWrapperProps, children: [backgroundContainer && (jsxRuntimeExports.jsx(NamiBackgroundContainer, { component: backgroundContainer, scaleFactor: scaleFactor, onClose: onClose })), hasHeader && (jsxRuntimeExports.jsx(NamiHeader, { components: header, scaleFactor: scaleFactor, onClose: onClose })), contentContainer && (jsxRuntimeExports.jsx(NamiContentContainer, { component: contentContainer, scaleFactor: scaleFactor, onClose: onClose })), hasFooter && (jsxRuntimeExports.jsx(NamiFooter, { components: footer, scaleFactor: scaleFactor, onClose: onClose })), shouldHoldForFocus && !focusReady && (jsxRuntimeExports.jsx(reactNative.View, { pointerEvents: "none", style: [styles$1.waitingOverlay, { backgroundColor: waitingOverlayColor }] }))] }, `${paywall.id}:${page.name ?? currentPageName}`));
|
|
3391
|
-
return (jsxRuntimeExports.jsx(reactNative.View, { style: [styles$1.screen, { backgroundColor: bgColor }], pointerEvents: interactionEnabled ? 'auto' : 'none', children: isTelevision ? (pageContent) : (jsxRuntimeExports.jsx(reactNative.Pressable, { style: styles$1.screen, onPress: () => reactNative.Keyboard.dismiss(), accessible: false, children: pageContent })) }));
|
|
3580
|
+
const pageContent = (jsxRuntimeExports.jsxs(PageFocusWrapper, { ...pageFocusWrapperProps, children: [jsxRuntimeExports.jsx(A11yLiveRegionMount, {}), backgroundContainer && (jsxRuntimeExports.jsx(NamiBackgroundContainer, { component: backgroundContainer, scaleFactor: scaleFactor, onClose: onClose })), hasHeader && (jsxRuntimeExports.jsx(NamiHeader, { components: header, scaleFactor: scaleFactor, onClose: onClose })), contentContainer && (jsxRuntimeExports.jsx(NamiContentContainer, { component: contentContainer, scaleFactor: scaleFactor, onClose: onClose })), hasFooter && (jsxRuntimeExports.jsx(NamiFooter, { components: footer, scaleFactor: scaleFactor, onClose: onClose })), shouldHoldForFocus && !focusReady && (jsxRuntimeExports.jsx(reactNative.View, { pointerEvents: "none", style: [styles$1.waitingOverlay, { backgroundColor: waitingOverlayColor }] }))] }, `${paywall.id}:${page.name ?? currentPageName}`));
|
|
3581
|
+
return (jsxRuntimeExports.jsx(AnnouncementProvider, { page: page, children: jsxRuntimeExports.jsx(reactNative.View, { style: [styles$1.screen, { backgroundColor: bgColor }], pointerEvents: interactionEnabled ? 'auto' : 'none', children: isTelevision ? (pageContent) : (jsxRuntimeExports.jsx(reactNative.Pressable, { style: styles$1.screen, onPress: () => reactNative.Keyboard.dismiss(), accessible: false, children: pageContent })) }) }, `ann:${paywall.id}:${page.name ?? currentPageName}`));
|
|
3582
|
+
};
|
|
3583
|
+
/**
|
|
3584
|
+
* NAM-403: renders the assertive live region using text from the announcer.
|
|
3585
|
+
* Must live under AnnouncementProvider so it can read useAnnouncer().
|
|
3586
|
+
*/
|
|
3587
|
+
const A11yLiveRegionMount = () => {
|
|
3588
|
+
const { liveRegionText } = useAnnouncer();
|
|
3589
|
+
return jsxRuntimeExports.jsx(A11yLiveRegion, { text: liveRegionText });
|
|
3392
3590
|
};
|
|
3393
3591
|
const styles$1 = reactNative.StyleSheet.create({
|
|
3394
3592
|
screen: { flex: 1 },
|