@applicaster/zapp-react-native-ui-components 16.0.0-alpha.1929433645 → 16.0.0-alpha.2105365027

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.
Files changed (39) hide show
  1. package/Components/FocusableList/index.tsx +7 -0
  2. package/Components/GeneralContentScreen/GeneralContentScreen.tsx +7 -0
  3. package/Components/MasterCell/DefaultComponents/LiveImage/index.tsx +12 -3
  4. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/ActionButton.tsx +1 -1
  5. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/__tests__/PressableView.test.tsx +4 -2
  6. package/Components/MasterCell/SharedUI/CollapsibleTextContainer/__tests__/factory.test.ts +2 -2
  7. package/Components/MasterCell/SharedUI/CollapsibleTextContainer/index.ts +2 -1
  8. package/Components/MasterCell/__tests__/masterCell.test.js +60 -0
  9. package/Components/MasterCell/index.tsx +60 -11
  10. package/Components/MasterCell/utils/__tests__/withoutFocusedState.test.js +20 -0
  11. package/Components/MasterCell/utils/index.ts +18 -0
  12. package/Components/ModalComponent/AudioPlayer/Components/Item.tsx +22 -1
  13. package/Components/ModalComponent/AudioPlayer/Components/__tests__/Item.test.tsx +52 -1
  14. package/Components/ModalComponent/AudioPlayer/utils/__tests__/mergeStyles.test.ts +77 -0
  15. package/Components/ModalComponent/AudioPlayer/utils/mergeStyles.ts +58 -16
  16. package/Components/ModalComponent/BottomSheetModalContent.tsx +9 -74
  17. package/Components/ModalComponent/SortableList.tsx +2 -3
  18. package/Components/ModalComponent/__tests__/showTextInput.test.ts +25 -0
  19. package/Components/ModalComponent/showTextInput.ts +2 -0
  20. package/Components/OfflineHandler/__tests__/index.test.tsx +109 -0
  21. package/Components/OfflineHandler/index.tsx +42 -4
  22. package/Components/PlayerContainer/PlayerContainer.tsx +8 -1
  23. package/Components/River/ComponentsMap/ComponentsMap.tsx +4 -1
  24. package/Components/River/__tests__/__snapshots__/componentsMap.test.js.snap +2 -0
  25. package/Components/Transitioner/AnimationManager.js +1 -1
  26. package/Components/Transitioner/Scene.tsx +4 -3
  27. package/Components/Transitioner/Transitioner.tsx +71 -33
  28. package/Components/Transitioner/__tests__/transitioner.test.js +372 -3
  29. package/Components/VideoModal/PlayerWrapper.tsx +4 -1
  30. package/Components/VideoModal/__tests__/PlayerWrapper.test.tsx +1 -0
  31. package/Components/VideoModal/__tests__/playerWrapperStyle.test.ts +58 -0
  32. package/Components/VideoModal/__tests__/playerWrapperUtils.test.ts +16 -0
  33. package/Components/VideoModal/playerWrapperStyle.ts +21 -3
  34. package/Components/VideoModal/playerWrapperUtils.ts +7 -1
  35. package/Decorators/ZappPipesDataConnector/__tests__/screenToolbox.test.ts +347 -0
  36. package/Decorators/ZappPipesDataConnector/__tests__/screenToolboxUnavailable.test.ts +53 -0
  37. package/Decorators/ZappPipesDataConnector/index.tsx +19 -4
  38. package/Decorators/ZappPipesDataConnector/screenToolbox.ts +384 -0
  39. package/package.json +5 -5
@@ -1,5 +1,6 @@
1
1
  import * as R from "ramda";
2
2
  import * as React from "react";
3
+ import { countRender } from "@applicaster/zapp-react-native-utils/perfTracker";
3
4
  import {
4
5
  FlatList,
5
6
  FlatListProps,
@@ -101,6 +102,12 @@ function FocusableListComponent<ItemT>(props: Props<ItemT>, ref) {
101
102
  onScrollToIndexFailed = noop,
102
103
  } = props;
103
104
 
105
+ countRender(`FocusableList:${props.id || "anonymous"}`, {
106
+ id: props.id,
107
+ horizontal: !!horizontal,
108
+ dataCount: data ? data.length : 0,
109
+ });
110
+
104
111
  useCheckItemIdsForUnique({ componentId: props.id, items: data });
105
112
 
106
113
  const { focused, nextFocusUp, nextFocusRight, nextFocusDown, nextFocusLeft } =
@@ -1,4 +1,5 @@
1
1
  import React, { useEffect } from "react";
2
+ import { countRender } from "@applicaster/zapp-react-native-utils/perfTracker";
2
3
  import { ComponentsMap } from "../River/ComponentsMap";
3
4
  import { CellTapContext } from "@applicaster/zapp-react-native-ui-components/Contexts/CellTapContext";
4
5
  import { useActions } from "@applicaster/zapp-react-native-utils/reactHooks/actions";
@@ -107,6 +108,12 @@ export const GeneralContentScreen = ({
107
108
 
108
109
  const isReady = useRiverActionProviderReady();
109
110
 
111
+ countRender("GeneralContentScreen", {
112
+ screenId,
113
+ isReady,
114
+ componentsCount: (components || uiComponents)?.length,
115
+ });
116
+
110
117
  const contextValue = React.useMemo(
111
118
  () => whenMatchingType(Function, cellTapAction) || onCellTapAction,
112
119
  [typeof cellTapAction === "function" ? cellTapAction : onCellTapAction]
@@ -245,10 +245,19 @@ const LiveImageComponent = (props: LiveImageProps) => {
245
245
 
246
246
  const isCurrentlyFocused = useTrackCurrentAutoScrollingElement(componentId);
247
247
 
248
- const { playerPluginId, screenConfig, preloadHooks } =
249
- getPlayerConfig(player_screen_id, actionIdentifier) ?? {};
248
+ // New object identities here restart PlayerLiveImage's register effect,
249
+ // which throws away the in-flight player and runs preload hooks again.
250
+ const playerConfig = React.useMemo(
251
+ () => getPlayerConfig(player_screen_id, actionIdentifier),
252
+ [player_screen_id, actionIdentifier]
253
+ );
254
+
255
+ const { playerPluginId, screenConfig, preloadHooks } = playerConfig ?? {};
250
256
 
251
- const playableEntry = playerPluginId ? getPlayableEntry(item) : null;
257
+ const playableEntry = React.useMemo(
258
+ () => (playerPluginId ? getPlayableEntry(item) : null),
259
+ [playerPluginId, item?.id]
260
+ );
252
261
 
253
262
  const playerPlugin = getPluginByIdentifier(playerPluginId);
254
263
 
@@ -166,7 +166,7 @@ export const ActionButton = ({
166
166
  <PressableView
167
167
  testOnly_pressed={props.testOnly_pressed}
168
168
  testID={props.testID || `${entry?.id}`}
169
- style={style}
169
+ style={[style, props.alignment]}
170
170
  pressedStyle={focusedStyles}
171
171
  onPress={renderProps.onPress}
172
172
  accessibilityLabel={props.accessibilityLabel || `${entry?.id}`}
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { Pressable, Text as RNText, View } from "react-native";
2
+ import { Pressable, StyleSheet, Text as RNText, View } from "react-native";
3
3
  import { render, fireEvent } from "@testing-library/react-native";
4
4
  import { useActions } from "@applicaster/zapp-react-native-utils/reactHooks/actions";
5
5
 
@@ -282,7 +282,9 @@ describe("MobileActionButton", () => {
282
282
  expect(pressable.props.testID).toBe("mobile_action_button_1");
283
283
  expect(pressable.props.accessibilityLabel).toBe("entry-1");
284
284
 
285
- expect(pressable.props.style({ pressed: false })[0]).toMatchObject({
285
+ expect(
286
+ StyleSheet.flatten(pressable.props.style({ pressed: false }))
287
+ ).toMatchObject({
286
288
  backgroundColor: "rgba(1,1,1,1)",
287
289
  borderColor: "rgba(2,2,2,1)",
288
290
  borderWidth: 1,
@@ -20,13 +20,13 @@ const getValue = (key: string) => {
20
20
  };
21
21
 
22
22
  describe("CollapsibleTextContainer view tree", () => {
23
- it("stretches to the parent width so labels wrap using the cell width", () => {
23
+ it("aligns the label to the text alignment instead of stretching the cell", () => {
24
24
  const result = CollapsibleTextContainer({
25
25
  getValue,
26
26
  elements: [],
27
27
  additionalStyles: { backgroundColor: "transparent" },
28
28
  });
29
29
 
30
- expect(result.style.alignSelf).toBe("stretch");
30
+ expect(result.style.alignSelf).toBe("flex-start");
31
31
  });
32
32
  });
@@ -3,6 +3,7 @@ import { isNilOrEmpty } from "@applicaster/zapp-react-native-utils/reactUtils/he
3
3
  import {
4
4
  getColorFromData,
5
5
  getKeyForLabel,
6
+ mapSelfAlignment,
6
7
  } from "@applicaster/zapp-react-native-utils/cellUtils";
7
8
  import { toNumberWithDefaultZero } from "@applicaster/zapp-react-native-utils/numberUtils";
8
9
 
@@ -22,7 +23,7 @@ export const CollapsibleTextContainer = ({
22
23
  return {
23
24
  type: "CollapsibleTextContainer", // Collapsible Text Container,
24
25
  style: {
25
- alignSelf: "stretch",
26
+ alignSelf: mapSelfAlignment(getValue("text_alignment")),
26
27
  marginTop: toNumberWithDefaultZero(getValue("margin_top")),
27
28
  marginRight: toNumberWithDefaultZero(getValue("margin_right")),
28
29
  marginBottom: toNumberWithDefaultZero(getValue("margin_bottom")),
@@ -76,6 +76,66 @@ describe("masterCellBuilder", () => {
76
76
  });
77
77
  });
78
78
 
79
+ describe("cellOptions.interactive", () => {
80
+ // renders a single View tagged with the state the cell was built with
81
+ const dataAdapter =
82
+ () =>
83
+ ({ state }) => [
84
+ { type: "View", style: {}, props: { testID: `state-${state}` } },
85
+ ];
86
+
87
+ // Only the root View gets this testID: cell elements pass their own testID, which overrides it
88
+ const ROOT_TEST_ID = "master-cell-root";
89
+
90
+ // forwardRef: MasterCell attaches a ref to its root View
91
+ const TestView = React.forwardRef((props, ref) => (
92
+ <View testID={ROOT_TEST_ID} {...props} ref={ref} />
93
+ ));
94
+
95
+ const renderCell = (cellOptions, state) => {
96
+ const MasterCell = masterCellBuilder({
97
+ cellConfiguration: singleView,
98
+ dataAdapter,
99
+ components: { View: TestView },
100
+ cellOptions,
101
+ });
102
+
103
+ return renderWithProviders(<MasterCell item={entry} state={state} />);
104
+ };
105
+
106
+ const getRoot = (wrapper) => wrapper.getByTestId(ROOT_TEST_ID);
107
+
108
+ it("keeps focused state and default root props when omitted", () => {
109
+ const wrapper = renderCell(undefined, "focused");
110
+ const root = getRoot(wrapper);
111
+
112
+ expect(wrapper.getByTestId("state-focused")).toBeDefined();
113
+ expect(root.props.onStartShouldSetResponder).toBeUndefined();
114
+ expect(root.props.collapsable).toBeUndefined();
115
+ });
116
+
117
+ it("claims body touches on a non-collapsable root when false", () => {
118
+ const wrapper = renderCell({ interactive: false }, "default");
119
+ const root = getRoot(wrapper);
120
+
121
+ expect(root.props.onStartShouldSetResponder()).toBe(true);
122
+ expect(root.props.collapsable).toBe(false);
123
+ });
124
+
125
+ it("renders focused as default when false", () => {
126
+ const wrapper = renderCell({ interactive: false }, "focused");
127
+
128
+ expect(wrapper.getByTestId("state-default")).toBeDefined();
129
+ expect(wrapper.queryByTestId("state-focused")).toBeNull();
130
+ });
131
+
132
+ it("renders focused_selected as selected when false", () => {
133
+ const wrapper = renderCell({ interactive: false }, "focused_selected");
134
+
135
+ expect(wrapper.getByTestId("state-selected")).toBeDefined();
136
+ });
137
+ });
138
+
79
139
  describe.skip("when using custom default components", () => {
80
140
  // the collapsable prop is added here to easily check from the snapshot that the mock
81
141
  // component is being used instead of the default one
@@ -1,5 +1,10 @@
1
1
  import * as R from "ramda";
2
2
  import * as React from "react";
3
+ import {
4
+ countRender,
5
+ countCellRender,
6
+ measureSync,
7
+ } from "@applicaster/zapp-react-native-utils/perfTracker";
3
8
  import { v4 as uuid } from "uuid";
4
9
 
5
10
  import { isHorizontalList } from "@applicaster/zapp-react-native-utils/componentsUtils";
@@ -10,7 +15,11 @@ import { useScreenState } from "@applicaster/zapp-react-native-utils/screenState
10
15
  import { defaultComponents } from "./DefaultComponents";
11
16
  import { defaultDataAdapter } from "./dataAdapter";
12
17
  import { elementMapper, mapElementWithKey } from "./elementMapper";
13
- import { getEntryState, hasFocusableInsideBuilder } from "./utils";
18
+ import {
19
+ getEntryState,
20
+ hasFocusableInsideBuilder,
21
+ withoutFocusedState,
22
+ } from "./utils";
14
23
 
15
24
  type Props = {
16
25
  item: ZappEntry;
@@ -19,6 +28,16 @@ type Props = {
19
28
 
20
29
  const defaultContainerStyle = { flex: 1 };
21
30
 
31
+ /**
32
+ * Root props of a non-interactive cell: the root view claims touches on the cell body,
33
+ * so the host list's touchable never fires (buttons, being deeper, still receive theirs).
34
+ * `collapsable: false` keeps Android / Fabric from flattening the view away.
35
+ */
36
+ const nonInteractiveRootProps = {
37
+ onStartShouldSetResponder: () => true,
38
+ collapsable: false,
39
+ };
40
+
22
41
  /**
23
42
  * Constructs a MasterCell with the provided options
24
43
  * @param {Object} options
@@ -27,6 +46,8 @@ const defaultContainerStyle = { flex: 1 };
27
46
  * @param {Function} options.dataAdapter optional dataAdapter to use instead of the default one.
28
47
  * See dataAdapter.js for more info
29
48
  * @param {containerStyles} options.containerStyle style to apply at the top level of the master cell
49
+ * @param {Object} options.cellOptions optional cell options. `interactive: false` makes the cell body
50
+ * ignore taps and render without focused / pressed states (inner buttons keep working). Defaults to `true`.
30
51
  * @returns {Function} Functional React Component to render the masterCell with the provided options
31
52
  */
32
53
 
@@ -39,6 +60,7 @@ type MasterCellBuilderOptions = {
39
60
  isRTL?: boolean;
40
61
  skipButtons?: boolean;
41
62
  component?: ZappComponent;
63
+ interactive?: boolean;
42
64
  };
43
65
  };
44
66
 
@@ -49,15 +71,25 @@ export function masterCellBuilder({
49
71
  containerStyle = defaultContainerStyle,
50
72
  cellOptions = {},
51
73
  }: MasterCellBuilderOptions) {
52
- const { skipButtons = false, component } = cellOptions;
74
+ countRender("masterCellBuilder_factory", {
75
+ hasCellConfig: !!cellConfiguration,
76
+ });
77
+
78
+ const { skipButtons = false, component, interactive = true } = cellOptions;
53
79
 
54
80
  const componentsMap = R.merge(defaultComponents, components);
55
81
  const { View } = componentsMap;
56
- const elementsBuilder = dataAdapter(cellConfiguration);
82
+
83
+ const elementsBuilder = measureSync("MasterCell:dataAdapter", () =>
84
+ dataAdapter(cellConfiguration)
85
+ );
57
86
 
58
87
  // This is very expensive operation
59
88
  // Keep calls to minimum
60
- const hasFocusableInside = hasFocusableInsideBuilder(elementsBuilder);
89
+ const hasFocusableInside = measureSync(
90
+ "MasterCell:hasFocusableInsideBuilder",
91
+ () => hasFocusableInsideBuilder(elementsBuilder)
92
+ );
61
93
 
62
94
  /**
63
95
  * Functional React Component to use to render the master cell
@@ -67,6 +99,11 @@ export function masterCellBuilder({
67
99
  * @param {"selected" | "focused" | null} props.state state of the component
68
100
  */
69
101
  function MasterCell({ item, state, ...otherProps }: Props) {
102
+ countCellRender("MasterCell", item?.id, {
103
+ state,
104
+ title: item?.title,
105
+ });
106
+
70
107
  const { screenData } = useRoute();
71
108
  const theme = useTheme();
72
109
 
@@ -86,12 +123,20 @@ export function masterCellBuilder({
86
123
 
87
124
  const elements = React.useMemo(
88
125
  () =>
89
- elementsBuilder({
90
- entry: item,
91
- state: getEntryState(state, entryIsSelected),
92
- allowDynamicColorsOutsideExtensions:
93
- theme?.allow_dynamic_colors_outside_extensions,
94
- }),
126
+ measureSync(
127
+ "MasterCell:elementsBuilder",
128
+ () =>
129
+ elementsBuilder({
130
+ entry: item,
131
+ state: getEntryState(
132
+ interactive ? state : withoutFocusedState(state),
133
+ entryIsSelected
134
+ ),
135
+ allowDynamicColorsOutsideExtensions:
136
+ theme?.allow_dynamic_colors_outside_extensions,
137
+ }),
138
+ { itemId: item?.id }
139
+ ),
95
140
  [
96
141
  state,
97
142
  item,
@@ -134,7 +179,11 @@ export function masterCellBuilder({
134
179
  );
135
180
 
136
181
  return (
137
- <View style={containerStyle} ref={wrapperRef}>
182
+ <View
183
+ style={containerStyle}
184
+ ref={wrapperRef}
185
+ {...(interactive ? undefined : nonInteractiveRootProps)}
186
+ >
138
187
  {elements.map(remapElements)}
139
188
  </View>
140
189
  );
@@ -0,0 +1,20 @@
1
+ import { withoutFocusedState } from "..";
2
+
3
+ describe("withoutFocusedState", () => {
4
+ it("maps focused to default", () => {
5
+ expect(withoutFocusedState("focused")).toBe("default");
6
+ });
7
+
8
+ it("maps focused_selected to selected", () => {
9
+ expect(withoutFocusedState("focused_selected")).toBe("selected");
10
+ });
11
+
12
+ it("keeps default and selected states", () => {
13
+ expect(withoutFocusedState("default")).toBe("default");
14
+ expect(withoutFocusedState("selected")).toBe("selected");
15
+ });
16
+
17
+ it("keeps a missing state", () => {
18
+ expect(withoutFocusedState(undefined)).toBeUndefined();
19
+ });
20
+ });
@@ -386,3 +386,21 @@ export function getEntryState(state, selected) {
386
386
 
387
387
  return selected ? "selected" : "default";
388
388
  }
389
+
390
+ /**
391
+ * Drops focused (pressed) variants of a cell state, so a non-interactive cell
392
+ * always renders its idle look. `selected` is kept – it is not touch feedback.
393
+ */
394
+ export function withoutFocusedState(
395
+ state?: CellState | null
396
+ ): CellState | null | undefined {
397
+ if (state === "focused_selected") {
398
+ return "selected";
399
+ }
400
+
401
+ if (state === "focused") {
402
+ return "default";
403
+ }
404
+
405
+ return state;
406
+ }
@@ -6,7 +6,7 @@
6
6
  * Green (configurable): background, image, nowPlayingAsset, nowPlayingLabel,
7
7
  * textLabel1, textLabel2
8
8
  * White (hardcoded): container padding/gutters, nowPlaying container layout,
9
- * button sizes/padding/assets
9
+ * button sizes/padding. Play/pause icons use plugin assets when set.
10
10
  *
11
11
  * Variants (VARIANT_*) are preset configuration bundles — not passed as a field.
12
12
  */
@@ -94,6 +94,10 @@ export type ActionButtonAssetsConfiguration = {
94
94
  removeAsset?: string;
95
95
  /** Trailing drag handle on Edit Order / queue rows. */
96
96
  dragAsset?: string;
97
+ /** Shown on the Now Playing row while the track is paused. */
98
+ playAsset?: string;
99
+ /** Shown on the Now Playing row while the track is playing. */
100
+ pauseAsset?: string;
97
101
  };
98
102
 
99
103
  export type ItemConfiguration = {
@@ -504,6 +508,23 @@ function ItemButton({
504
508
  const renderIcon = () => {
505
509
  switch (action) {
506
510
  case "playPause": {
511
+ const playbackAsset = isSelected
512
+ ? actionButtonAssets?.pauseAsset
513
+ : actionButtonAssets?.playAsset;
514
+
515
+ if (playbackAsset) {
516
+ return (
517
+ <Image
518
+ source={{ uri: playbackAsset }}
519
+ style={{
520
+ width: layout.assetWidth,
521
+ height: layout.assetHeight,
522
+ }}
523
+ resizeMode="contain"
524
+ />
525
+ );
526
+ }
527
+
507
528
  const iconSize = layout.assetWidth;
508
529
  const iconRadius = iconSize / 2;
509
530
 
@@ -1,8 +1,10 @@
1
1
  import React from "react";
2
- import { Pressable } from "react-native";
2
+ import { Image, Pressable } from "react-native";
3
3
  import { fireEvent, render, screen } from "@testing-library/react-native";
4
+ import { ModalBlocksStyleContext } from "../../../ModalBlocksStyleContext";
4
5
  import {
5
6
  AudioPlayerTrackItem,
7
+ VARIANT_NOW_PLAYING,
6
8
  VARIANT_PLAYLIST_ITEM,
7
9
  mapMenuItemToNowPlayingData,
8
10
  } from "../Item";
@@ -101,6 +103,55 @@ describe("mapMenuItemToNowPlayingData", () => {
101
103
  expect(data.textLabel2).toBe("Artist Name");
102
104
  });
103
105
 
106
+ it("uses the plugin play asset while paused and the pause asset while playing", () => {
107
+ const menuItem = {
108
+ id: "item_1",
109
+ title: "Track Title",
110
+ summary: "Artist Name",
111
+ };
112
+
113
+ const style = {
114
+ nowPlayingItem: {
115
+ actionButtons: {
116
+ default: {
117
+ playAsset: "https://example.com/play.png",
118
+ pauseAsset: "https://example.com/pause.png",
119
+ },
120
+ },
121
+ },
122
+ };
123
+
124
+ const { UNSAFE_getAllByType, rerender } = render(
125
+ <ModalBlocksStyleContext.Provider value={style}>
126
+ <AudioPlayerTrackItem
127
+ configuration={VARIANT_NOW_PLAYING}
128
+ data={mapMenuItemToNowPlayingData(menuItem as any, true)}
129
+ />
130
+ </ModalBlocksStyleContext.Provider>
131
+ );
132
+
133
+ expect(
134
+ UNSAFE_getAllByType(Image).some(
135
+ (node) => node.props.source?.uri === "https://example.com/play.png"
136
+ )
137
+ ).toBe(true);
138
+
139
+ rerender(
140
+ <ModalBlocksStyleContext.Provider value={style}>
141
+ <AudioPlayerTrackItem
142
+ configuration={VARIANT_NOW_PLAYING}
143
+ data={mapMenuItemToNowPlayingData(menuItem as any, false)}
144
+ />
145
+ </ModalBlocksStyleContext.Provider>
146
+ );
147
+
148
+ expect(
149
+ UNSAFE_getAllByType(Image).some(
150
+ (node) => node.props.source?.uri === "https://example.com/pause.png"
151
+ )
152
+ ).toBe(true);
153
+ });
154
+
104
155
  it("uses custom nowPlayingLabel when provided", () => {
105
156
  const data = mapMenuItemToNowPlayingData(
106
157
  item as any,
@@ -131,6 +131,43 @@ describe("mergeItemConfiguration background", () => {
131
131
  "https://placehold.co/24x24/FF3B30/FFFFFF/png?text=RH"
132
132
  );
133
133
  });
134
+
135
+ it("applies a text label font family and ignores a non-string font family", () => {
136
+ const withFont = mergeItemConfiguration(base as any, {
137
+ textLabel1: { default: { fontFamily: "Roboto-Bold" } },
138
+ });
139
+
140
+ expect(withFont.textLabel1.iosFontFamily).toBe("Roboto-Bold");
141
+ expect(withFont.textLabel1.androidFontFamily).toBe("Roboto-Bold");
142
+
143
+ const withEmpty = mergeItemConfiguration(base as any, {
144
+ textLabel1: {
145
+ default: { fontFamily: {}, iosFontFamily: "SFProText-Semibold" },
146
+ },
147
+ });
148
+
149
+ expect(withEmpty.textLabel1.iosFontFamily).toBe("SFProText-Semibold");
150
+ expect(withEmpty.textLabel1.androidFontFamily).toBeUndefined();
151
+ });
152
+
153
+ it("applies play and pause assets onto actionButtonAssets", () => {
154
+ const merged = mergeItemConfiguration(base as any, {
155
+ actionButtons: {
156
+ default: {
157
+ playAsset: "https://example.com/play.png",
158
+ pauseAsset: "https://example.com/pause.png",
159
+ },
160
+ },
161
+ });
162
+
163
+ expect(merged.actionButtonAssets?.playAsset).toBe(
164
+ "https://example.com/play.png"
165
+ );
166
+
167
+ expect(merged.actionButtonAssets?.pauseAsset).toBe(
168
+ "https://example.com/pause.png"
169
+ );
170
+ });
134
171
  });
135
172
 
136
173
  describe("mergeButtonConfiguration background", () => {
@@ -250,6 +287,46 @@ describe("mergeButtonConfiguration background", () => {
250
287
  expect(merged.title.androidLetterSpacing).toBe(-0.5);
251
288
  expect(merged.title.textTransform).toBe("uppercase");
252
289
  });
290
+
291
+ it("uses the family string inside a font-selector object and ignores {}", () => {
292
+ const fromObject = mergeButtonConfiguration(buttonBase as any, {
293
+ title: {
294
+ default: { fontFamily: { fontFamily: "GreycliffCF-Bold" } },
295
+ },
296
+ });
297
+
298
+ expect(fromObject.title.iosFontFamily).toBe("GreycliffCF-Bold");
299
+ expect(fromObject.title.androidFontFamily).toBe("GreycliffCF-Bold");
300
+
301
+ const fromEmpty = mergeButtonConfiguration(buttonBase as any, {
302
+ title: {
303
+ default: { fontFamily: {} },
304
+ },
305
+ });
306
+
307
+ expect(fromEmpty.title.iosFontFamily).toBe(buttonBase.title.iosFontFamily);
308
+
309
+ expect(fromEmpty.title.androidFontFamily).toBe(
310
+ buttonBase.title.androidFontFamily
311
+ );
312
+ });
313
+
314
+ it("maps inactive color, gutter, and focused and inactive font colors", () => {
315
+ const merged = mergeButtonConfiguration(buttonBase as any, {
316
+ background: {
317
+ default: { inactiveColor: "#999999", horizontalGutter: 6 },
318
+ },
319
+ title: {
320
+ default: { inactiveFontColor: "#111111" },
321
+ focused: { fontColor: "#222222" },
322
+ },
323
+ });
324
+
325
+ expect(merged.background.inactiveColor).toBe("#999999");
326
+ expect(merged.background.horizontalGutter).toBe(6);
327
+ expect(merged.title.inactiveFontColor).toBe("#111111");
328
+ expect(merged.title.focusedFontColor).toBe("#222222");
329
+ });
253
330
  });
254
331
 
255
332
  describe("mergeHeaderConfiguration section header (Variant 3)", () => {