@applicaster/zapp-react-native-ui-components 16.0.0-alpha.3081324238 → 16.0.0-alpha.3202041045

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 (36) hide show
  1. package/Components/MasterCell/DefaultComponents/ActionButton.tsx +13 -0
  2. package/Components/MasterCell/DefaultComponents/LiveImage/index.tsx +12 -3
  3. package/Components/MasterCell/DefaultComponents/PressableView.tsx +3 -0
  4. package/Components/MasterCell/DefaultComponents/__tests__/ActionButton.test.tsx +288 -0
  5. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/ActionButton.tsx +2 -1
  6. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/__tests__/PressableView.test.tsx +4 -2
  7. package/Components/MasterCell/__tests__/masterCell.test.js +60 -0
  8. package/Components/MasterCell/index.tsx +28 -4
  9. package/Components/MasterCell/utils/__tests__/withoutFocusedState.test.js +20 -0
  10. package/Components/MasterCell/utils/index.ts +18 -0
  11. package/Components/ModalComponent/AudioPlayer/utils/__tests__/mergeStyles.test.ts +40 -0
  12. package/Components/ModalComponent/AudioPlayer/utils/mergeStyles.ts +55 -15
  13. package/Components/ModalComponent/BottomSheetModalContent.tsx +5 -1
  14. package/Components/ModalComponent/__tests__/showTextInput.test.ts +25 -0
  15. package/Components/ModalComponent/showTextInput.ts +2 -0
  16. package/Components/PlayerContainer/PlayerContainer.tsx +22 -5
  17. package/Components/PlayerContainer/__tests__/PlayerContainer.test.tsx +51 -0
  18. package/Components/River/ComponentsMap/ComponentsMap.tsx +4 -1
  19. package/Components/River/__tests__/__snapshots__/componentsMap.test.js.snap +2 -0
  20. package/Components/VideoLive/LiveImageManager.ts +126 -12
  21. package/Components/VideoLive/MuteUnmuteController.ts +135 -0
  22. package/Components/VideoLive/PlayerLiveImageComponent.tsx +15 -19
  23. package/Components/VideoLive/__tests__/LiveImageMuteState.test.ts +311 -0
  24. package/Components/VideoLive/__tests__/MuteUnmuteController.test.ts +135 -0
  25. package/Components/VideoLive/__tests__/PlayerLiveImageComponent.test.tsx +63 -1
  26. package/Components/VideoModal/PlayerWrapper.tsx +4 -1
  27. package/Components/VideoModal/__tests__/PlayerWrapper.test.tsx +1 -0
  28. package/Components/VideoModal/__tests__/playerWrapperStyle.test.ts +58 -0
  29. package/Components/VideoModal/__tests__/playerWrapperUtils.test.ts +16 -0
  30. package/Components/VideoModal/playerWrapperStyle.ts +21 -3
  31. package/Components/VideoModal/playerWrapperUtils.ts +7 -1
  32. package/Decorators/ZappPipesDataConnector/__tests__/screenToolbox.test.ts +347 -0
  33. package/Decorators/ZappPipesDataConnector/__tests__/screenToolboxUnavailable.test.ts +53 -0
  34. package/Decorators/ZappPipesDataConnector/index.tsx +19 -4
  35. package/Decorators/ZappPipesDataConnector/screenToolbox.ts +384 -0
  36. package/package.json +5 -5
@@ -96,6 +96,14 @@ export const ActionButton = React.memo(function ActionButtonComponent(
96
96
  }, [item?.id, setActionState, actionContext]);
97
97
 
98
98
  if (actionDisabled || !actionContext) return null;
99
+
100
+ if (
101
+ actionState?.visibleInCells &&
102
+ !actionState.visibleInCells.includes(cellUUID)
103
+ ) {
104
+ return null;
105
+ }
106
+
99
107
  const AssetComponent = actionState?.asset;
100
108
 
101
109
  // Default state is not yet ready
@@ -109,6 +117,11 @@ export const ActionButton = React.memo(function ActionButtonComponent(
109
117
  accessibilityLabel={props?.accessibilityLabel || `${item?.id}`}
110
118
  accessibilityHint={props?.accessibilityHint}
111
119
  accessibilityRole="button"
120
+ accessibilityState={
121
+ typeof actionState.checked === "boolean"
122
+ ? { checked: actionState.checked }
123
+ : undefined
124
+ }
112
125
  accessible={!!(props?.testID || props?.accessibilityLabel)}
113
126
  style={props?.style}
114
127
  >
@@ -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
 
@@ -11,6 +11,7 @@ type Props = {
11
11
  accessibilityHint?: string;
12
12
  onPress?: () => void;
13
13
  testOnly_pressed?: boolean;
14
+ checked?: boolean;
14
15
  };
15
16
 
16
17
  export function PressableView({
@@ -22,11 +23,13 @@ export function PressableView({
22
23
  pressedStyle,
23
24
  onPress,
24
25
  testOnly_pressed = false,
26
+ checked,
25
27
  }: Props) {
26
28
  return (
27
29
  <Pressable
28
30
  testOnly_pressed={testOnly_pressed}
29
31
  onPress={onPress}
32
+ accessibilityState={checked === undefined ? undefined : { checked }}
30
33
  testID={testID}
31
34
  accessibilityLabel={accessibilityLabel}
32
35
  accessibilityHint={accessibilityHint}
@@ -0,0 +1,288 @@
1
+ import React from "react";
2
+ import { act, fireEvent, render } from "@testing-library/react-native";
3
+
4
+ import { ActionButton } from "../ActionButton";
5
+ import { ActionButton as MobileActionButton } from "../mobile/MobileActionButtons/ActionButton";
6
+ import { Text } from "react-native";
7
+
8
+ type EntryStateListener = (state: CellActionEntryState) => void;
9
+
10
+ const PLUGIN_ASSETS = {
11
+ mute: ["plugin_mute_1", "plugin_mute_2"],
12
+ unmute: ["plugin_unmute_1", "plugin_unmute_2"],
13
+ };
14
+
15
+ // Fake of the mute_unmute_action context, following its entry state contract.
16
+ type FakeState = {
17
+ available: boolean;
18
+ muted: boolean;
19
+ visibleInCells?: string[];
20
+ };
21
+
22
+ const createFakeContext = (initial: FakeState) => {
23
+ let current = initial;
24
+ const listeners = new Set<EntryStateListener>();
25
+
26
+ const toEntryState = ({
27
+ available,
28
+ muted,
29
+ visibleInCells,
30
+ }: FakeState): CellActionEntryState => ({
31
+ state: muted ? 1 : 0,
32
+ asset: available
33
+ ? muted
34
+ ? PLUGIN_ASSETS.unmute
35
+ : PLUGIN_ASSETS.mute
36
+ : undefined,
37
+ label: muted ? "Unmute" : "Mute",
38
+ mobileButtonAssets: muted ? PLUGIN_ASSETS.unmute : PLUGIN_ASSETS.mute,
39
+ visibleInCells,
40
+ });
41
+
42
+ const emit = (next: FakeState) => {
43
+ current = next;
44
+ listeners.forEach((listener) => listener(toEntryState(current)));
45
+ };
46
+
47
+ const context = {
48
+ initialEntryState: jest.fn(() => toEntryState(current)),
49
+ addListener: jest.fn((_id: string, listener: EntryStateListener) => {
50
+ listeners.add(listener);
51
+
52
+ return () => listeners.delete(listener);
53
+ }),
54
+ invokeAction: jest.fn((_item, options) => {
55
+ if (!current.available) return;
56
+
57
+ current = { ...current, muted: !current.muted };
58
+ options?.updateState?.(toEntryState(current));
59
+ listeners.forEach((listener) => listener(toEntryState(current)));
60
+ }),
61
+ };
62
+
63
+ return { context, emit, listeners };
64
+ };
65
+
66
+ let mockContext: ReturnType<typeof createFakeContext>["context"];
67
+
68
+ jest.mock("@applicaster/zapp-react-native-utils/reactHooks/actions", () => ({
69
+ useActions: jest.fn(() => mockContext),
70
+ }));
71
+
72
+ jest.mock(
73
+ "@applicaster/zapp-react-native-ui-components/Contexts/UIComponentContext",
74
+ () => ({ useUIComponentContext: jest.fn(() => undefined) })
75
+ );
76
+
77
+ jest.mock("../Image", () => {
78
+ const { Text: MockText } = require("react-native");
79
+
80
+ return {
81
+ __esModule: true,
82
+ default: ({ uri }) => <MockText testID="asset">{uri}</MockText>,
83
+ };
84
+ });
85
+
86
+ const item = { id: "entry-1" } as unknown as ZappEntry;
87
+
88
+ it("keeps the standalone button visible and limits actions to the playing cell instance", () => {
89
+ const fake = createFakeContext({
90
+ available: true,
91
+ muted: true,
92
+ visibleInCells: [],
93
+ });
94
+
95
+ mockContext = fake.context;
96
+
97
+ const Label = ({
98
+ children,
99
+ testID,
100
+ }: {
101
+ children?: React.ReactNode;
102
+ testID?: string;
103
+ mobileActionRole?: string;
104
+ }) => <Text testID={testID}>{children}</Text>;
105
+
106
+ const { getByTestId, queryByTestId } = render(
107
+ <>
108
+ {["first", "second"].map((cellUUID) => (
109
+ <ActionButton
110
+ key={cellUUID}
111
+ item={item}
112
+ action={{ identifier: "mute_unmute_action" }}
113
+ cellUUID={cellUUID}
114
+ testID={`cell-${cellUUID}`}
115
+ style={{}}
116
+ />
117
+ ))}
118
+ <MobileActionButton
119
+ action={{ identifier: "mute_unmute_action" }}
120
+ entry={item}
121
+ testID="standalone"
122
+ style={{}}
123
+ focusedStyles={{}}
124
+ >
125
+ <Label mobileActionRole="label">Toggle sound</Label>
126
+ </MobileActionButton>
127
+ </>
128
+ );
129
+
130
+ expect(queryByTestId("cell-first")).toBeNull();
131
+ expect(queryByTestId("cell-second")).toBeNull();
132
+ expect(getByTestId("standalone")).toBeTruthy();
133
+ fireEvent.press(getByTestId("standalone"));
134
+ expect(fake.context.invokeAction).toHaveBeenCalledTimes(1);
135
+
136
+ act(() =>
137
+ fake.emit({ available: true, muted: false, visibleInCells: ["first"] })
138
+ );
139
+
140
+ expect(getByTestId("cell-first")).toBeTruthy();
141
+ expect(queryByTestId("cell-second")).toBeNull();
142
+
143
+ act(() =>
144
+ fake.emit({ available: true, muted: false, visibleInCells: ["second"] })
145
+ );
146
+
147
+ expect(queryByTestId("cell-first")).toBeNull();
148
+ expect(getByTestId("cell-second")).toBeTruthy();
149
+
150
+ act(() => fake.emit({ available: true, muted: false, visibleInCells: [] }));
151
+
152
+ expect(queryByTestId("cell-first")).toBeNull();
153
+ expect(queryByTestId("cell-second")).toBeNull();
154
+ expect(getByTestId("standalone")).toBeTruthy();
155
+ });
156
+
157
+ const renderButton = (
158
+ flavour: "flavour_1" | "flavour_2",
159
+ asset?: React.ComponentProps<typeof ActionButton>["asset"]
160
+ ) =>
161
+ render(
162
+ <ActionButton
163
+ item={item}
164
+ flavour={flavour}
165
+ action={{ identifier: "mute_unmute_action" }}
166
+ asset={asset}
167
+ style={{}}
168
+ testID="mute-button"
169
+ />
170
+ );
171
+
172
+ const cellAsset = {
173
+ props: {},
174
+ src: { active: "cell_selected", inactive: "cell_unselected" },
175
+ style: {},
176
+ type: "Image",
177
+ } as const;
178
+
179
+ describe.each([
180
+ ["flavour_1", 0],
181
+ ["flavour_2", 1],
182
+ ] as const)("mute_unmute_action cell action (%s)", (flavour, index) => {
183
+ let fake: ReturnType<typeof createFakeContext>;
184
+
185
+ const setup = (state: { available: boolean; muted: boolean }, asset?) => {
186
+ fake = createFakeContext(state);
187
+ mockContext = fake.context;
188
+
189
+ return renderButton(flavour, asset);
190
+ };
191
+
192
+ it("renders nothing while the preview has not started", () => {
193
+ const { queryByTestId } = setup({ available: false, muted: false });
194
+
195
+ expect(queryByTestId("mute-button")).toBeNull();
196
+ expect(queryByTestId("asset")).toBeNull();
197
+ });
198
+
199
+ it("shows the Mute icon while the preview plays with sound on", () => {
200
+ const { getByTestId } = setup({ available: true, muted: false });
201
+
202
+ expect(getByTestId("asset").props.children).toBe(PLUGIN_ASSETS.mute[index]);
203
+ });
204
+
205
+ it("shows the Unmute icon while the preview plays muted", () => {
206
+ const { getByTestId } = setup({ available: true, muted: true });
207
+
208
+ expect(getByTestId("asset").props.children).toBe(
209
+ PLUGIN_ASSETS.unmute[index]
210
+ );
211
+ });
212
+
213
+ it("toggles the sound and updates the icon on tap", () => {
214
+ const { getByTestId } = setup({ available: true, muted: false });
215
+
216
+ fireEvent.press(getByTestId("mute-button"));
217
+
218
+ expect(fake.context.invokeAction).toHaveBeenCalledTimes(1);
219
+
220
+ expect(getByTestId("asset").props.children).toBe(
221
+ PLUGIN_ASSETS.unmute[index]
222
+ );
223
+
224
+ fireEvent.press(getByTestId("mute-button"));
225
+
226
+ expect(getByTestId("asset").props.children).toBe(PLUGIN_ASSETS.mute[index]);
227
+ });
228
+
229
+ it("follows state emitted through addListener", () => {
230
+ const { getByTestId, queryByTestId } = setup({
231
+ available: false,
232
+ muted: false,
233
+ });
234
+
235
+ act(() => fake.emit({ available: true, muted: false }));
236
+
237
+ expect(getByTestId("asset").props.children).toBe(PLUGIN_ASSETS.mute[index]);
238
+
239
+ act(() => fake.emit({ available: true, muted: true }));
240
+
241
+ expect(getByTestId("asset").props.children).toBe(
242
+ PLUGIN_ASSETS.unmute[index]
243
+ );
244
+
245
+ expect(queryByTestId("mute-button")).not.toBeNull();
246
+ });
247
+
248
+ it("hides again when the preview stops or another cell starts", () => {
249
+ const { queryByTestId } = setup({ available: true, muted: false });
250
+
251
+ expect(queryByTestId("mute-button")).not.toBeNull();
252
+
253
+ act(() => fake.emit({ available: false, muted: false }));
254
+
255
+ expect(queryByTestId("mute-button")).toBeNull();
256
+ });
257
+
258
+ it("unsubscribes from the context on unmount", () => {
259
+ const { unmount } = setup({ available: true, muted: false });
260
+
261
+ expect(fake.listeners.size).toBe(1);
262
+
263
+ unmount();
264
+
265
+ expect(fake.listeners.size).toBe(0);
266
+ });
267
+
268
+ it("prefers the cell's selected icon when muted (state 1)", () => {
269
+ const { getByTestId } = setup({ available: true, muted: true }, cellAsset);
270
+
271
+ expect(getByTestId("asset").props.children).toBe("cell_selected");
272
+ });
273
+
274
+ it("prefers the cell's unselected icon when sound is on (state 0)", () => {
275
+ const { getByTestId } = setup({ available: true, muted: false }, cellAsset);
276
+
277
+ expect(getByTestId("asset").props.children).toBe("cell_unselected");
278
+ });
279
+
280
+ it("uses the plugin asset when the cell has no icons", () => {
281
+ const { getByTestId } = setup({ available: true, muted: false }, {
282
+ ...cellAsset,
283
+ src: { active: undefined, inactive: undefined },
284
+ } as any);
285
+
286
+ expect(getByTestId("asset").props.children).toBe(PLUGIN_ASSETS.mute[index]);
287
+ });
288
+ });
@@ -166,9 +166,10 @@ 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
+ checked={renderProps.actionState?.checked}
172
173
  accessibilityLabel={props.accessibilityLabel || `${entry?.id}`}
173
174
  accessibilityHint={props.accessibilityHint}
174
175
  >
@@ -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,
@@ -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
@@ -10,7 +10,11 @@ import { useScreenState } from "@applicaster/zapp-react-native-utils/screenState
10
10
  import { defaultComponents } from "./DefaultComponents";
11
11
  import { defaultDataAdapter } from "./dataAdapter";
12
12
  import { elementMapper, mapElementWithKey } from "./elementMapper";
13
- import { getEntryState, hasFocusableInsideBuilder } from "./utils";
13
+ import {
14
+ getEntryState,
15
+ hasFocusableInsideBuilder,
16
+ withoutFocusedState,
17
+ } from "./utils";
14
18
 
15
19
  type Props = {
16
20
  item: ZappEntry;
@@ -19,6 +23,16 @@ type Props = {
19
23
 
20
24
  const defaultContainerStyle = { flex: 1 };
21
25
 
26
+ /**
27
+ * Root props of a non-interactive cell: the root view claims touches on the cell body,
28
+ * so the host list's touchable never fires (buttons, being deeper, still receive theirs).
29
+ * `collapsable: false` keeps Android / Fabric from flattening the view away.
30
+ */
31
+ const nonInteractiveRootProps = {
32
+ onStartShouldSetResponder: () => true,
33
+ collapsable: false,
34
+ };
35
+
22
36
  /**
23
37
  * Constructs a MasterCell with the provided options
24
38
  * @param {Object} options
@@ -27,6 +41,8 @@ const defaultContainerStyle = { flex: 1 };
27
41
  * @param {Function} options.dataAdapter optional dataAdapter to use instead of the default one.
28
42
  * See dataAdapter.js for more info
29
43
  * @param {containerStyles} options.containerStyle style to apply at the top level of the master cell
44
+ * @param {Object} options.cellOptions optional cell options. `interactive: false` makes the cell body
45
+ * ignore taps and render without focused / pressed states (inner buttons keep working). Defaults to `true`.
30
46
  * @returns {Function} Functional React Component to render the masterCell with the provided options
31
47
  */
32
48
 
@@ -39,6 +55,7 @@ type MasterCellBuilderOptions = {
39
55
  isRTL?: boolean;
40
56
  skipButtons?: boolean;
41
57
  component?: ZappComponent;
58
+ interactive?: boolean;
42
59
  };
43
60
  };
44
61
 
@@ -49,7 +66,7 @@ export function masterCellBuilder({
49
66
  containerStyle = defaultContainerStyle,
50
67
  cellOptions = {},
51
68
  }: MasterCellBuilderOptions) {
52
- const { skipButtons = false, component } = cellOptions;
69
+ const { skipButtons = false, component, interactive = true } = cellOptions;
53
70
 
54
71
  const componentsMap = R.merge(defaultComponents, components);
55
72
  const { View } = componentsMap;
@@ -88,7 +105,10 @@ export function masterCellBuilder({
88
105
  () =>
89
106
  elementsBuilder({
90
107
  entry: item,
91
- state: getEntryState(state, entryIsSelected),
108
+ state: getEntryState(
109
+ interactive ? state : withoutFocusedState(state),
110
+ entryIsSelected
111
+ ),
92
112
  allowDynamicColorsOutsideExtensions:
93
113
  theme?.allow_dynamic_colors_outside_extensions,
94
114
  }),
@@ -134,7 +154,11 @@ export function masterCellBuilder({
134
154
  );
135
155
 
136
156
  return (
137
- <View style={containerStyle} ref={wrapperRef}>
157
+ <View
158
+ style={containerStyle}
159
+ ref={wrapperRef}
160
+ {...(interactive ? undefined : nonInteractiveRootProps)}
161
+ >
138
162
  {elements.map(remapElements)}
139
163
  </View>
140
164
  );
@@ -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
+ }
@@ -287,6 +287,46 @@ describe("mergeButtonConfiguration background", () => {
287
287
  expect(merged.title.androidLetterSpacing).toBe(-0.5);
288
288
  expect(merged.title.textTransform).toBe("uppercase");
289
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
+ });
290
330
  });
291
331
 
292
332
  describe("mergeHeaderConfiguration section header (Variant 3)", () => {