@applicaster/zapp-react-native-ui-components 15.1.15 → 15.1.16-rc.0

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.
@@ -1,11 +1,10 @@
1
1
  /**
2
2
  * Modal Building Block — Header
3
3
  *
4
- * Single-file Expo Snack demo for the Header building block spec.
5
- *
6
4
  * Green (configurable): title, closeButton (enable + asset), actionButtons styling
7
- * White (hardcoded): container, action button layout/enable/actions, close dimensions,
8
- * action button padding/radius, action labels
5
+ * (incl. Clear padding/radius for Variant 3 / section_header Studio keys)
6
+ * White (hardcoded): container gutters, action button slot enablement/actions,
7
+ * close dimensions, default action labels (overridable via HeaderData.clearLabel)
9
8
  */
10
9
 
11
10
  import React, { useCallback, useState } from "react";
@@ -94,6 +93,12 @@ export type ActionButtonsStyleConfiguration = {
94
93
  iosLetterSpacing: number;
95
94
  androidLetterSpacing: number;
96
95
  textTransform: TextTransform;
96
+ /** Green Variant 3 fields — defaults match ACTION_BUTTON_LAYOUT_SPEC. */
97
+ paddingTop?: number;
98
+ paddingRight?: number;
99
+ paddingBottom?: number;
100
+ paddingLeft?: number;
101
+ cornerRadius?: number;
97
102
  };
98
103
 
99
104
  export type HeaderConfiguration = {
@@ -133,6 +138,11 @@ export const DEFAULT_ACTION_BUTTONS_STYLE_CONFIGURATION: ActionButtonsStyleConfi
133
138
  iosLetterSpacing: 0,
134
139
  androidLetterSpacing: 0,
135
140
  textTransform: "default",
141
+ paddingTop: 6,
142
+ paddingRight: 10,
143
+ paddingBottom: 6,
144
+ paddingLeft: 10,
145
+ cornerRadius: 16,
136
146
  };
137
147
 
138
148
  // ---------------------------------------------------------------------------
@@ -141,6 +151,8 @@ export const DEFAULT_ACTION_BUTTONS_STYLE_CONFIGURATION: ActionButtonsStyleConfi
141
151
 
142
152
  export type HeaderData = {
143
153
  title: string;
154
+ /** Localization override for Clear; falls back to ACTION_LABELS.clear. */
155
+ clearLabel?: string;
144
156
  };
145
157
 
146
158
  export type AudioPlayerHeaderProps = {
@@ -437,6 +449,7 @@ type HeaderActionButtonProps = {
437
449
  actionType: HeaderActionType;
438
450
  configuration: ActionButtonsStyleConfiguration;
439
451
  focused?: boolean;
452
+ label?: string;
440
453
  onPress?: () => void;
441
454
  };
442
455
 
@@ -444,8 +457,24 @@ function HeaderActionButton({
444
457
  actionType,
445
458
  configuration,
446
459
  focused = false,
460
+ label,
447
461
  onPress,
448
462
  }: HeaderActionButtonProps) {
463
+ const paddingTop =
464
+ configuration.paddingTop ?? ACTION_BUTTON_LAYOUT_SPEC.paddingTop;
465
+
466
+ const paddingRight =
467
+ configuration.paddingRight ?? ACTION_BUTTON_LAYOUT_SPEC.paddingRight;
468
+
469
+ const paddingBottom =
470
+ configuration.paddingBottom ?? ACTION_BUTTON_LAYOUT_SPEC.paddingBottom;
471
+
472
+ const paddingLeft =
473
+ configuration.paddingLeft ?? ACTION_BUTTON_LAYOUT_SPEC.paddingLeft;
474
+
475
+ const cornerRadius =
476
+ configuration.cornerRadius ?? ACTION_BUTTON_LAYOUT_SPEC.cornerRadius;
477
+
449
478
  return (
450
479
  <Pressable
451
480
  onPress={onPress}
@@ -454,11 +483,11 @@ function HeaderActionButton({
454
483
  pressed || focused
455
484
  ? configuration.focusedBackgroundColor
456
485
  : configuration.defaultBackgroundColor,
457
- borderRadius: ACTION_BUTTON_LAYOUT_SPEC.cornerRadius,
458
- paddingTop: ACTION_BUTTON_LAYOUT_SPEC.paddingTop,
459
- paddingRight: ACTION_BUTTON_LAYOUT_SPEC.paddingRight,
460
- paddingBottom: ACTION_BUTTON_LAYOUT_SPEC.paddingBottom,
461
- paddingLeft: ACTION_BUTTON_LAYOUT_SPEC.paddingLeft,
486
+ borderRadius: cornerRadius,
487
+ paddingTop,
488
+ paddingRight,
489
+ paddingBottom,
490
+ paddingLeft,
462
491
  })}
463
492
  >
464
493
  <Text
@@ -481,7 +510,7 @@ function HeaderActionButton({
481
510
  }}
482
511
  numberOfLines={1}
483
512
  >
484
- {ACTION_LABELS[actionType]}
513
+ {label || ACTION_LABELS[actionType]}
485
514
  </Text>
486
515
  </Pressable>
487
516
  );
@@ -594,14 +623,20 @@ export function AudioPlayerHeader({
594
623
  onClear,
595
624
  }: AudioPlayerHeaderProps) {
596
625
  const modalBlocksStyle = useModalBlocksStyle();
597
- const headerBlock = modalBlocksStyle?.header;
626
+
627
+ // Next Up section header uses sectionHeader — never Theme modal `header`,
628
+ // so the Queue title styles do not collide with the section title.
629
+ const styleBlock =
630
+ configuration.variant === "section"
631
+ ? modalBlocksStyle?.sectionHeader
632
+ : modalBlocksStyle?.header;
598
633
 
599
634
  const effectiveConfig = React.useMemo(() => {
600
- return mergeHeaderConfiguration(configuration, headerBlock);
601
- }, [configuration, headerBlock]);
635
+ return mergeHeaderConfiguration(configuration, styleBlock);
636
+ }, [configuration, styleBlock]);
602
637
 
603
638
  const { variant, title, closeButton, actionButtons } = effectiveConfig;
604
- const { title: titleText } = data;
639
+ const { title: titleText, clearLabel } = data;
605
640
 
606
641
  const container = CONTAINER_SPEC[variant];
607
642
  const behavior = ACTION_BUTTONS_BEHAVIOR[variant];
@@ -612,16 +647,24 @@ export function AudioPlayerHeader({
612
647
  behavior.sectionEnabled
613
648
  );
614
649
 
615
- const showTrailingAction = shouldShowActionSlot(
616
- behavior.trailing,
617
- behavior.sectionEnabled
618
- );
650
+ const showTrailingAction =
651
+ shouldShowActionSlot(behavior.trailing, behavior.sectionEnabled) &&
652
+ (behavior.trailing.actionType !== "clear" || !!onClear);
653
+
654
+ const actionLabelFor = (actionType?: HeaderActionType) => {
655
+ if (actionType === "clear" && clearLabel) {
656
+ return clearLabel;
657
+ }
658
+
659
+ return actionType ? ACTION_LABELS[actionType] : undefined;
660
+ };
619
661
 
620
662
  const leadingContent = showLeadingAction ? (
621
663
  <HeaderActionButton
622
- actionType={behavior.leading.actionType}
664
+ actionType={behavior.leading.actionType as HeaderActionType}
623
665
  configuration={actionButtons}
624
666
  focused={focusedLeadingAction}
667
+ label={actionLabelFor(behavior.leading.actionType)}
625
668
  onPress={onCancel}
626
669
  />
627
670
  ) : onBack ? (
@@ -630,9 +673,10 @@ export function AudioPlayerHeader({
630
673
 
631
674
  const trailingContent = showTrailingAction ? (
632
675
  <HeaderActionButton
633
- actionType={behavior.trailing.actionType}
676
+ actionType={behavior.trailing.actionType as HeaderActionType}
634
677
  configuration={actionButtons}
635
678
  focused={focusedTrailingAction}
679
+ label={actionLabelFor(behavior.trailing.actionType)}
636
680
  onPress={behavior.trailing.actionType === "done" ? onDone : onClear}
637
681
  />
638
682
  ) : closeButton.enabled && !isCenterAligned ? (
@@ -54,6 +54,8 @@ export type BackgroundConfiguration = {
54
54
  paddingRight?: number;
55
55
  paddingBottom?: number;
56
56
  paddingLeft?: number;
57
+ highlightInsetLeft?: number;
58
+ highlightInsetRight?: number;
57
59
  };
58
60
 
59
61
  export type ImageConfiguration = {
@@ -203,9 +205,9 @@ const CONTAINER_SPEC_LARGE_IMAGE: ContainerSpec = {
203
205
 
204
206
  const CONTAINER_SPEC_DEFAULT: ContainerSpec = {
205
207
  paddingTop: 8,
206
- paddingRight: 20,
208
+ paddingRight: 10,
207
209
  paddingBottom: 8,
208
- paddingLeft: 20,
210
+ paddingLeft: 10,
209
211
  horizontalGutter: 12,
210
212
  verticalGutter: 2,
211
213
  };
@@ -350,21 +352,30 @@ function ItemContainer({
350
352
  onPress,
351
353
  }: ItemContainerProps) {
352
354
  const container = resolveContainerSpec(image);
353
- const bg = focused ? background.focusedColor : background.defaultColor;
355
+ const insetLeft = background.highlightInsetLeft ?? 0;
356
+ const insetRight = background.highlightInsetRight ?? 0;
354
357
 
355
358
  return (
356
- <Pressable
357
- onPress={onPress}
358
- style={({ pressed }) => ({
359
- backgroundColor: pressed ? background.focusedColor : bg,
360
- borderRadius: background.cornerRadius,
361
- paddingTop: background.paddingTop ?? container.paddingTop,
362
- paddingRight: background.paddingRight ?? container.paddingRight,
363
- paddingBottom: background.paddingBottom ?? container.paddingBottom,
364
- paddingLeft: background.paddingLeft ?? container.paddingLeft,
365
- })}
366
- >
367
- {children}
359
+ <Pressable onPress={onPress}>
360
+ {({ pressed }) => (
361
+ <View
362
+ style={{
363
+ marginLeft: insetLeft,
364
+ marginRight: insetRight,
365
+ backgroundColor:
366
+ pressed || focused
367
+ ? background.focusedColor
368
+ : background.defaultColor,
369
+ borderRadius: background.cornerRadius,
370
+ paddingTop: background.paddingTop ?? container.paddingTop,
371
+ paddingRight: background.paddingRight ?? container.paddingRight,
372
+ paddingBottom: background.paddingBottom ?? container.paddingBottom,
373
+ paddingLeft: background.paddingLeft ?? container.paddingLeft,
374
+ }}
375
+ >
376
+ {children}
377
+ </View>
378
+ )}
368
379
  </Pressable>
369
380
  );
370
381
  }
@@ -927,6 +938,10 @@ export const VARIANT_NOW_PLAYING: ItemConfiguration = {
927
938
  defaultColor: "transparent",
928
939
  focusedColor: "transparent",
929
940
  cornerRadius: 0,
941
+ paddingTop: 8,
942
+ paddingRight: 20,
943
+ paddingBottom: 8,
944
+ paddingLeft: 20,
930
945
  },
931
946
  image: {
932
947
  width: 72,
@@ -947,6 +962,12 @@ export const VARIANT_QUEUED_ITEM: ItemConfiguration = {
947
962
  defaultColor: "transparent",
948
963
  focusedColor: "rgba(62, 62, 62, 1)",
949
964
  cornerRadius: 12,
965
+ paddingTop: 8,
966
+ paddingRight: 10,
967
+ paddingBottom: 8,
968
+ paddingLeft: 10,
969
+ highlightInsetLeft: 10,
970
+ highlightInsetRight: 10,
950
971
  },
951
972
  image: {
952
973
  width: 48,
@@ -967,6 +988,10 @@ export const VARIANT_PLAYLIST_ITEM: ItemConfiguration = {
967
988
  defaultColor: "transparent",
968
989
  focusedColor: "rgba(62, 62, 62, 1)",
969
990
  cornerRadius: 12,
991
+ paddingTop: 8,
992
+ paddingRight: 10,
993
+ paddingBottom: 8,
994
+ paddingLeft: 10,
970
995
  },
971
996
  image: {
972
997
  width: 48,
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { StyleSheet, View } from "react-native";
2
+ import { StyleSheet, Text, View } from "react-native";
3
3
  import {
4
4
  AudioPlayerTrackItem,
5
5
  VARIANT_NOW_PLAYING,
@@ -10,6 +10,8 @@ import type { MenuItem } from "@applicaster/zapp-react-native-utils/modalState/t
10
10
  import { usePlayer } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayer";
11
11
  import { usePlayerState } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayerState";
12
12
 
13
+ const DEFAULT_EMPTY_MESSAGE = "Queued items will be added here";
14
+
13
15
  const styles = StyleSheet.create({
14
16
  divider: {
15
17
  height: StyleSheet.hairlineWidth,
@@ -17,18 +19,33 @@ const styles = StyleSheet.create({
17
19
  marginHorizontal: 20,
18
20
  marginVertical: 8,
19
21
  },
22
+ emptyMessage: {
23
+ paddingTop: 32,
24
+ paddingBottom: 32,
25
+ paddingHorizontal: 40,
26
+ textAlign: "center",
27
+ color: "#999999",
28
+ fontSize: 13,
29
+ lineHeight: 16,
30
+ },
20
31
  });
21
32
 
22
33
  type NowPlayingHeaderSectionProps = {
23
34
  onClear?: () => void;
24
35
  nextUpTitle?: string;
25
36
  nowPlayingTitle?: string;
37
+ isEmpty?: boolean;
38
+ emptyMessage?: string;
39
+ clearLabel?: string;
26
40
  };
27
41
 
28
42
  export function NowPlayingHeaderSection({
29
43
  onClear,
30
44
  nextUpTitle = "Next Up",
31
45
  nowPlayingTitle = "Now Playing",
46
+ isEmpty = false,
47
+ emptyMessage = DEFAULT_EMPTY_MESSAGE,
48
+ clearLabel,
32
49
  }: NowPlayingHeaderSectionProps) {
33
50
  const player = usePlayer();
34
51
  const playerState = usePlayerState("queue-now-playing-section");
@@ -70,14 +87,11 @@ export function NowPlayingHeaderSection({
70
87
  };
71
88
 
72
89
  return (
73
- <View>
74
- <AudioPlayerTrackItem
75
- configuration={VARIANT_NOW_PLAYING}
76
- data={data}
77
- onTrailingButtonPress={handlePlayPause}
78
- />
79
- <View style={styles.divider} />
80
- </View>
90
+ <AudioPlayerTrackItem
91
+ configuration={VARIANT_NOW_PLAYING}
92
+ data={data}
93
+ onTrailingButtonPress={handlePlayPause}
94
+ />
81
95
  );
82
96
  };
83
97
 
@@ -86,9 +100,10 @@ export function NowPlayingHeaderSection({
86
100
  {renderNowPlayingTrack()}
87
101
  <AudioPlayerHeader
88
102
  configuration={VARIANT_SECTION_HEADER}
89
- data={{ title: nextUpTitle }}
103
+ data={{ title: nextUpTitle, clearLabel }}
90
104
  onClear={onClear}
91
105
  />
106
+ {isEmpty ? <Text style={styles.emptyMessage}>{emptyMessage}</Text> : null}
92
107
  </View>
93
108
  );
94
109
  }
@@ -0,0 +1,36 @@
1
+ import React from "react";
2
+ import { render, screen } from "@testing-library/react-native";
3
+ import { NowPlayingHeaderSection } from "../NowPlayingHeaderSection";
4
+
5
+ jest.mock(
6
+ "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayer",
7
+ () => ({
8
+ usePlayer: () => null,
9
+ })
10
+ );
11
+
12
+ jest.mock(
13
+ "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayerState",
14
+ () => ({
15
+ usePlayerState: () => ({ isPaused: true }),
16
+ })
17
+ );
18
+
19
+ const EMPTY_COPY = "Queued items will be added here";
20
+
21
+ describe("NowPlayingHeaderSection empty queue", () => {
22
+ it("renders the empty message under Next Up when the list is empty", () => {
23
+ render(<NowPlayingHeaderSection isEmpty emptyMessage={EMPTY_COPY} />);
24
+
25
+ expect(screen.getByText("Next Up")).toBeTruthy();
26
+ expect(screen.getByText(EMPTY_COPY)).toBeTruthy();
27
+ });
28
+
29
+ it("does not render the empty message when the list has items", () => {
30
+ render(
31
+ <NowPlayingHeaderSection isEmpty={false} emptyMessage={EMPTY_COPY} />
32
+ );
33
+
34
+ expect(screen.queryByText(EMPTY_COPY)).toBeNull();
35
+ });
36
+ });
@@ -1,7 +1,12 @@
1
1
  import {
2
2
  mergeItemConfiguration,
3
3
  mergeButtonConfiguration,
4
+ mergeHeaderConfiguration,
4
5
  } from "../mergeStyles";
6
+ import {
7
+ VARIANT_SECTION_HEADER,
8
+ VARIANT_SINGLE_LINE_HEADER,
9
+ } from "../../Components/Header";
5
10
 
6
11
  const base = {
7
12
  background: {
@@ -57,7 +62,7 @@ describe("mergeItemConfiguration background", () => {
57
62
  expect(merged.textLabel1.lineHeight).toBe(40);
58
63
  });
59
64
 
60
- it("merges background padding from Studio keys", () => {
65
+ it("does not merge Studio item background padding (white spec field)", () => {
61
66
  const merged = mergeItemConfiguration(base as any, {
62
67
  background: {
63
68
  default: {
@@ -70,11 +75,11 @@ describe("mergeItemConfiguration background", () => {
70
75
  },
71
76
  });
72
77
 
73
- expect(merged.background.paddingTop).toBe(32);
74
- expect(merged.background.paddingBottom).toBe(32);
75
- expect(merged.background.paddingLeft).toBe(40);
76
- expect(merged.background.paddingRight).toBe(40);
77
- // Default-only override must not steal the base focused highlight color.
78
+ expect(merged.background.defaultColor).toBe("#2E7D32");
79
+ expect(merged.background.paddingTop).toBeUndefined();
80
+ expect(merged.background.paddingBottom).toBeUndefined();
81
+ expect(merged.background.paddingLeft).toBeUndefined();
82
+ expect(merged.background.paddingRight).toBeUndefined();
78
83
  expect(merged.background.focusedColor).toBe("rgba(62, 62, 62, 1)");
79
84
  });
80
85
 
@@ -246,3 +251,106 @@ describe("mergeButtonConfiguration background", () => {
246
251
  expect(merged.title.textTransform).toBe("uppercase");
247
252
  });
248
253
  });
254
+
255
+ describe("mergeHeaderConfiguration section header (Variant 3)", () => {
256
+ it("merges section title + Clear action button styles", () => {
257
+ const merged = mergeHeaderConfiguration(VARIANT_SECTION_HEADER, {
258
+ title: {
259
+ default: {
260
+ fontColor: "#ABCDEF",
261
+ fontSize: 15,
262
+ lineHeight: 36,
263
+ fontFamily: "CustomSection",
264
+ letterSpacing: -0.2,
265
+ textTransform: "uppercase",
266
+ alignment: "left",
267
+ },
268
+ },
269
+ actionButtons: {
270
+ default: {
271
+ backgroundColor: "rgba(10, 10, 10, 1)",
272
+ fontColor: "#EEEEEE",
273
+ fontSize: 12,
274
+ borderRadius: 20,
275
+ paddingTop: 8,
276
+ paddingRight: 12,
277
+ paddingBottom: 8,
278
+ paddingLeft: 12,
279
+ },
280
+ focused: {
281
+ backgroundColor: "rgba(20, 20, 20, 1)",
282
+ fontColor: "#FFDD00",
283
+ },
284
+ },
285
+ });
286
+
287
+ expect(merged.title.fontColor).toBe("#ABCDEF");
288
+ expect(merged.title.fontSize).toBe(15);
289
+ expect(merged.title.lineHeight).toBe(36);
290
+ expect(merged.title.iosFontFamily).toBe("CustomSection");
291
+ expect(merged.title.androidFontFamily).toBe("CustomSection");
292
+ expect(merged.title.iosLetterSpacing).toBe(-0.2);
293
+ expect(merged.title.textTransform).toBe("uppercase");
294
+
295
+ expect(merged.actionButtons.defaultBackgroundColor).toBe(
296
+ "rgba(10, 10, 10, 1)"
297
+ );
298
+
299
+ expect(merged.actionButtons.focusedBackgroundColor).toBe(
300
+ "rgba(20, 20, 20, 1)"
301
+ );
302
+
303
+ expect(merged.actionButtons.fontColor).toBe("#EEEEEE");
304
+ expect(merged.actionButtons.focusedFontColor).toBe("#FFDD00");
305
+ expect(merged.actionButtons.fontSize).toBe(12);
306
+ expect(merged.actionButtons.cornerRadius).toBe(20);
307
+ expect(merged.actionButtons.paddingTop).toBe(8);
308
+ expect(merged.actionButtons.paddingLeft).toBe(12);
309
+ });
310
+
311
+ it("uses title.default when only platform font and letter spacing are set", () => {
312
+ const merged = mergeHeaderConfiguration(VARIANT_SECTION_HEADER, {
313
+ title: {
314
+ default: {
315
+ iosFontFamily: "SFProText-Semibold",
316
+ androidFontFamily: "Roboto-Medium",
317
+ iosLetterSpacing: -0.4,
318
+ androidLetterSpacing: 0,
319
+ },
320
+ },
321
+ });
322
+
323
+ expect(merged.title.iosFontFamily).toBe("SFProText-Semibold");
324
+ expect(merged.title.androidFontFamily).toBe("Roboto-Medium");
325
+ expect(merged.title.iosLetterSpacing).toBe(-0.4);
326
+ expect(merged.title.androidLetterSpacing).toBe(0);
327
+ expect(merged.title.fontSize).toBe(VARIANT_SECTION_HEADER.title.fontSize);
328
+ });
329
+
330
+ it("does not change section defaults when block is empty", () => {
331
+ const merged = mergeHeaderConfiguration(VARIANT_SECTION_HEADER, {
332
+ title: { default: {} },
333
+ actionButtons: { default: {} },
334
+ });
335
+
336
+ expect(merged.title.fontSize).toBe(VARIANT_SECTION_HEADER.title.fontSize);
337
+
338
+ expect(merged.actionButtons.defaultBackgroundColor).toBe(
339
+ VARIANT_SECTION_HEADER.actionButtons.defaultBackgroundColor
340
+ );
341
+ });
342
+
343
+ it("leaves single-line header title size untouched when only actionButtons set", () => {
344
+ const merged = mergeHeaderConfiguration(VARIANT_SINGLE_LINE_HEADER, {
345
+ actionButtons: {
346
+ default: { backgroundColor: "#123456" },
347
+ },
348
+ });
349
+
350
+ expect(merged.title.fontSize).toBe(
351
+ VARIANT_SINGLE_LINE_HEADER.title.fontSize
352
+ );
353
+
354
+ expect(merged.actionButtons.defaultBackgroundColor).toBe("#123456");
355
+ });
356
+ });
@@ -9,6 +9,7 @@ import {
9
9
  } from "../Components/Item";
10
10
  import {
11
11
  TitleConfiguration as HeaderTitleConfiguration,
12
+ ActionButtonsStyleConfiguration as HeaderActionButtonsStyleConfiguration,
12
13
  HeaderConfiguration,
13
14
  } from "../Components/Header";
14
15
  import {
@@ -45,18 +46,6 @@ function mergeItemBackground(
45
46
  ...(defaultOverride?.borderRadius !== undefined
46
47
  ? { cornerRadius: defaultOverride.borderRadius }
47
48
  : {}),
48
- ...(defaultOverride?.paddingTop !== undefined
49
- ? { paddingTop: defaultOverride.paddingTop }
50
- : {}),
51
- ...(defaultOverride?.paddingRight !== undefined
52
- ? { paddingRight: defaultOverride.paddingRight }
53
- : {}),
54
- ...(defaultOverride?.paddingBottom !== undefined
55
- ? { paddingBottom: defaultOverride.paddingBottom }
56
- : {}),
57
- ...(defaultOverride?.paddingLeft !== undefined
58
- ? { paddingLeft: defaultOverride.paddingLeft }
59
- : {}),
60
49
  };
61
50
  }
62
51
 
@@ -222,6 +211,122 @@ function mergeHeaderTitle(
222
211
  ...base,
223
212
  ...(override.fontColor ? { fontColor: override.fontColor } : {}),
224
213
  ...(override.fontSize !== undefined ? { fontSize: override.fontSize } : {}),
214
+ ...(override.lineHeight !== undefined
215
+ ? { lineHeight: override.lineHeight }
216
+ : {}),
217
+ ...(typeof override.fontFamily === "string"
218
+ ? {
219
+ iosFontFamily: override.fontFamily,
220
+ androidFontFamily: override.fontFamily,
221
+ }
222
+ : {}),
223
+ ...(override.iosFontFamily
224
+ ? { iosFontFamily: override.iosFontFamily }
225
+ : {}),
226
+ ...(override.androidFontFamily
227
+ ? { androidFontFamily: override.androidFontFamily }
228
+ : {}),
229
+ ...(override.letterSpacing !== undefined
230
+ ? {
231
+ iosLetterSpacing: override.letterSpacing,
232
+ androidLetterSpacing: override.letterSpacing,
233
+ }
234
+ : {}),
235
+ ...(override.iosLetterSpacing !== undefined
236
+ ? { iosLetterSpacing: override.iosLetterSpacing }
237
+ : {}),
238
+ ...(override.androidLetterSpacing !== undefined
239
+ ? { androidLetterSpacing: override.androidLetterSpacing }
240
+ : {}),
241
+ ...(override.textTransform
242
+ ? { textTransform: override.textTransform }
243
+ : {}),
244
+ ...(override.alignment ? { alignment: override.alignment } : {}),
245
+ };
246
+ }
247
+
248
+ function mergeHeaderActionButtons(
249
+ base: HeaderActionButtonsStyleConfiguration,
250
+ defaultOverride?: Record<string, any>,
251
+ focusedOverride?: Record<string, any>
252
+ ): HeaderActionButtonsStyleConfiguration {
253
+ if (!defaultOverride && !focusedOverride) return base;
254
+
255
+ const focusedBackgroundColor =
256
+ focusedOverride?.backgroundColor ??
257
+ defaultOverride?.focusBackgroundColor ??
258
+ defaultOverride?.focusedBackgroundColor;
259
+
260
+ const focusedFontColor =
261
+ focusedOverride?.fontColor ??
262
+ defaultOverride?.focusFontColor ??
263
+ defaultOverride?.focusedFontColor;
264
+
265
+ return {
266
+ ...base,
267
+ ...(defaultOverride?.backgroundColor
268
+ ? { defaultBackgroundColor: defaultOverride.backgroundColor }
269
+ : {}),
270
+ ...(focusedBackgroundColor != null && focusedBackgroundColor !== ""
271
+ ? { focusedBackgroundColor }
272
+ : {}),
273
+ ...(defaultOverride?.fontColor
274
+ ? { fontColor: defaultOverride.fontColor }
275
+ : {}),
276
+ ...(focusedFontColor != null && focusedFontColor !== ""
277
+ ? { focusedFontColor }
278
+ : {}),
279
+ ...(defaultOverride?.fontSize !== undefined
280
+ ? { fontSize: defaultOverride.fontSize }
281
+ : {}),
282
+ ...(defaultOverride?.lineHeight !== undefined
283
+ ? { lineHeight: defaultOverride.lineHeight }
284
+ : {}),
285
+ ...(typeof defaultOverride?.fontFamily === "string"
286
+ ? {
287
+ iosFontFamily: defaultOverride.fontFamily,
288
+ androidFontFamily: defaultOverride.fontFamily,
289
+ }
290
+ : {}),
291
+ ...(defaultOverride?.iosFontFamily
292
+ ? { iosFontFamily: defaultOverride.iosFontFamily }
293
+ : {}),
294
+ ...(defaultOverride?.androidFontFamily
295
+ ? { androidFontFamily: defaultOverride.androidFontFamily }
296
+ : {}),
297
+ ...(defaultOverride?.letterSpacing !== undefined
298
+ ? {
299
+ iosLetterSpacing: defaultOverride.letterSpacing,
300
+ androidLetterSpacing: defaultOverride.letterSpacing,
301
+ }
302
+ : {}),
303
+ ...(defaultOverride?.iosLetterSpacing !== undefined
304
+ ? { iosLetterSpacing: defaultOverride.iosLetterSpacing }
305
+ : {}),
306
+ ...(defaultOverride?.androidLetterSpacing !== undefined
307
+ ? { androidLetterSpacing: defaultOverride.androidLetterSpacing }
308
+ : {}),
309
+ ...(defaultOverride?.textTransform
310
+ ? { textTransform: defaultOverride.textTransform }
311
+ : {}),
312
+ ...(defaultOverride?.borderRadius !== undefined
313
+ ? { cornerRadius: defaultOverride.borderRadius }
314
+ : {}),
315
+ ...(defaultOverride?.cornerRadius !== undefined
316
+ ? { cornerRadius: defaultOverride.cornerRadius }
317
+ : {}),
318
+ ...(defaultOverride?.paddingTop !== undefined
319
+ ? { paddingTop: defaultOverride.paddingTop }
320
+ : {}),
321
+ ...(defaultOverride?.paddingRight !== undefined
322
+ ? { paddingRight: defaultOverride.paddingRight }
323
+ : {}),
324
+ ...(defaultOverride?.paddingBottom !== undefined
325
+ ? { paddingBottom: defaultOverride.paddingBottom }
326
+ : {}),
327
+ ...(defaultOverride?.paddingLeft !== undefined
328
+ ? { paddingLeft: defaultOverride.paddingLeft }
329
+ : {}),
225
330
  };
226
331
  }
227
332
 
@@ -233,9 +338,39 @@ export function mergeHeaderConfiguration(
233
338
 
234
339
  const bgOverride = headerBlock.container?.default?.backgroundColor;
235
340
 
341
+ const actionButtonsDefault =
342
+ headerBlock.actionButtons?.default || headerBlock.actionButtons;
343
+
344
+ const actionButtonsFocused =
345
+ headerBlock.actionButtons?.focused || headerBlock.actionButtons?.focus;
346
+
347
+ // Parser shape is title.default.*; hydrate() returns a flat title object.
348
+ const titleDefault = headerBlock.title?.default;
349
+
350
+ const titleOverride =
351
+ titleDefault &&
352
+ (titleDefault.fontColor !== undefined ||
353
+ titleDefault.fontSize !== undefined ||
354
+ titleDefault.lineHeight !== undefined ||
355
+ titleDefault.fontFamily ||
356
+ titleDefault.iosFontFamily ||
357
+ titleDefault.androidFontFamily ||
358
+ titleDefault.letterSpacing !== undefined ||
359
+ titleDefault.iosLetterSpacing !== undefined ||
360
+ titleDefault.androidLetterSpacing !== undefined ||
361
+ titleDefault.textTransform ||
362
+ titleDefault.alignment)
363
+ ? titleDefault
364
+ : headerBlock.title;
365
+
236
366
  return {
237
367
  ...base,
238
- title: mergeHeaderTitle(base.title, headerBlock.title?.default),
368
+ title: mergeHeaderTitle(base.title, titleOverride),
369
+ actionButtons: mergeHeaderActionButtons(
370
+ base.actionButtons,
371
+ actionButtonsDefault,
372
+ actionButtonsFocused
373
+ ),
239
374
  ...(bgOverride ? { backgroundColor: bgOverride } : {}),
240
375
  };
241
376
  }
@@ -93,6 +93,8 @@ type ModalComponentProps = {
93
93
  contentComponentProps?: Record<string, any>;
94
94
  styleOverrides?: ModalBlocksConfig | Record<string, any>;
95
95
  measurementPhase?: boolean;
96
+ /** Copy under Next Up when the queue has no upcoming items. */
97
+ emptyMessage?: string;
96
98
  };
97
99
 
98
100
  function _BottomSheetModalContent(props: ModalComponentProps) {
@@ -116,6 +118,7 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
116
118
  contentComponentProps = {},
117
119
  styleOverrides: propStyleOverrides,
118
120
  measurementPhase = false,
121
+ emptyMessage,
119
122
  } = props;
120
123
 
121
124
  const route = useRef(currentRoute);
@@ -151,17 +154,81 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
151
154
  [plugins, themePluginId]
152
155
  );
153
156
 
154
- const styleOverrides = useMemo(() => {
155
- if (propStyleOverrides) {
156
- return propStyleOverrides;
157
- }
158
-
157
+ const styleOverrides = useMemo<ModalBlocksConfig | null>(() => {
159
158
  const configuration =
160
159
  plugin?.configuration || (plugin as any)?.configuration_json;
161
160
 
162
- if (!configuration || !themePluginId) return null;
161
+ const parsed =
162
+ configuration && themePluginId
163
+ ? parseModalBlocksConfiguration(configuration, themePluginId)
164
+ : null;
165
+
166
+ if (!propStyleOverrides) {
167
+ return parsed;
168
+ }
169
+
170
+ // hydrate(QueueStyles) only emits sectionHeader after the plugin class
171
+ // includes it. Until then, Studio/local keys in configuration_json still
172
+ // apply via the parser.
173
+ const hydratedSection = propStyleOverrides.sectionHeader;
174
+
175
+ const hasTitleStyle = (style?: Record<string, any>) =>
176
+ Boolean(
177
+ style &&
178
+ (style.fontColor ||
179
+ style.fontSize !== undefined ||
180
+ style.lineHeight !== undefined ||
181
+ style.fontFamily ||
182
+ style.iosFontFamily ||
183
+ style.androidFontFamily ||
184
+ style.letterSpacing !== undefined ||
185
+ style.iosLetterSpacing !== undefined ||
186
+ style.androidLetterSpacing !== undefined ||
187
+ style.textTransform ||
188
+ style.alignment)
189
+ );
190
+
191
+ const hasActionStyle = (style?: Record<string, any>) =>
192
+ Boolean(
193
+ style &&
194
+ (style.backgroundColor ||
195
+ style.fontColor ||
196
+ style.fontSize !== undefined ||
197
+ style.lineHeight !== undefined ||
198
+ style.fontFamily ||
199
+ style.iosFontFamily ||
200
+ style.androidFontFamily ||
201
+ style.letterSpacing !== undefined ||
202
+ style.iosLetterSpacing !== undefined ||
203
+ style.androidLetterSpacing !== undefined ||
204
+ style.textTransform ||
205
+ style.borderRadius !== undefined ||
206
+ style.cornerRadius !== undefined ||
207
+ style.paddingTop !== undefined ||
208
+ style.paddingRight !== undefined ||
209
+ style.paddingBottom !== undefined ||
210
+ style.paddingLeft !== undefined)
211
+ );
163
212
 
164
- return parseModalBlocksConfiguration(configuration, themePluginId);
213
+ const actionNode = hydratedSection?.actionButtons;
214
+
215
+ // An empty `default: {}` is truthy and would hide fields set on the flat
216
+ // object or on the focused bucket.
217
+ const hasHydratedSection =
218
+ hasTitleStyle(hydratedSection?.title?.default) ||
219
+ hasTitleStyle(hydratedSection?.title) ||
220
+ hasActionStyle(actionNode?.default) ||
221
+ hasActionStyle(actionNode?.focused) ||
222
+ hasActionStyle(actionNode?.focus) ||
223
+ hasActionStyle(actionNode);
224
+
225
+ return {
226
+ ...parsed,
227
+ ...propStyleOverrides,
228
+ sectionHeader: hasHydratedSection
229
+ ? hydratedSection
230
+ : parsed?.sectionHeader,
231
+ };
165
232
  }, [propStyleOverrides, plugin, themePluginId]);
166
233
 
167
234
  const pluginLocalizations = useLocalizedStrings({
@@ -182,17 +249,26 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
182
249
  const nextUpTitle =
183
250
  pluginLocalizations?.next_up_title ||
184
251
  (normalizedPluginId
185
- ? pluginLocalizations?.[`${normalizedPluginId}_next_up_title`]
252
+ ? pluginLocalizations?.[`${normalizedPluginId}_next_up_title`] ||
253
+ pluginLocalizations?.[`${normalizedPluginId}_label_next_up`]
186
254
  : undefined) ||
187
255
  "Next Up";
188
256
 
189
257
  const nowPlayingTitle =
190
258
  pluginLocalizations?.now_playing_title ||
191
259
  (normalizedPluginId
192
- ? pluginLocalizations?.[`${normalizedPluginId}_now_playing_title`]
260
+ ? pluginLocalizations?.[`${normalizedPluginId}_now_playing_title`] ||
261
+ pluginLocalizations?.[`${normalizedPluginId}_label_now_playing`]
193
262
  : undefined) ||
194
263
  "Now Playing";
195
264
 
265
+ const clearLabel =
266
+ pluginLocalizations?.clear_label ||
267
+ (normalizedPluginId
268
+ ? pluginLocalizations?.[`${normalizedPluginId}_label_clear`]
269
+ : undefined) ||
270
+ "Clear";
271
+
196
272
  const resolvedTitle = props.title || localizedHeaderTitle || title;
197
273
 
198
274
  const itemsToRender = viewModel ? fetchedItems : items;
@@ -316,6 +392,7 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
316
392
  const hasNowPlaying = hasOperation(operations, OPERATIONS.NOW_PLAYING);
317
393
  const hasClearAll = hasOperation(operations, OPERATIONS.CLEAR_ALL);
318
394
  const hasAdd = hasOperation(operations, OPERATIONS.ADD);
395
+ const isQueueEmpty = hasNowPlaying && (itemsToRender?.length ?? 0) === 0;
319
396
 
320
397
  const handleDeleteCollection = useCallback(() => {
321
398
  editableCollection?.deleteCollection?.();
@@ -340,6 +417,9 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
340
417
  onClear={hasClearAll ? handleDeleteCollection : undefined}
341
418
  nextUpTitle={nextUpTitle}
342
419
  nowPlayingTitle={nowPlayingTitle}
420
+ isEmpty={isQueueEmpty}
421
+ emptyMessage={emptyMessage}
422
+ clearLabel={clearLabel}
343
423
  />
344
424
  ),
345
425
  });
@@ -369,6 +449,9 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
369
449
  handleDeleteCollection,
370
450
  nextUpTitle,
371
451
  nowPlayingTitle,
452
+ isQueueEmpty,
453
+ emptyMessage,
454
+ clearLabel,
372
455
  hasAdd,
373
456
  handleCreatePlaylist,
374
457
  createPlaylistTitle,
@@ -479,6 +562,9 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
479
562
  onClear={hasClearAll ? handleDeleteCollection : undefined}
480
563
  nextUpTitle={nextUpTitle}
481
564
  nowPlayingTitle={nowPlayingTitle}
565
+ isEmpty={isQueueEmpty}
566
+ emptyMessage={emptyMessage}
567
+ clearLabel={clearLabel}
482
568
  />
483
569
  ) : null}
484
570
  {hasAdd ? (
@@ -82,14 +82,23 @@ jest.mock("../AudioPlayer/Components/Item", () => ({
82
82
  VARIANT_PLAYLIST_ITEM: {},
83
83
  }));
84
84
 
85
+ const mockAudioPlayerButtonProps: Array<any> = [];
86
+ const mockNowPlayingHeaderProps: Array<any> = [];
87
+
85
88
  jest.mock("../AudioPlayer/Components", () => ({
86
- NowPlayingHeaderSection: () => null,
87
- AudioPlayerButton: ({ data, onPress }: any) => {
89
+ NowPlayingHeaderSection: (props: any) => {
90
+ mockNowPlayingHeaderProps.push(props);
91
+
92
+ return null;
93
+ },
94
+ AudioPlayerButton: (props: any) => {
88
95
  const { Text } = require("react-native");
89
96
 
97
+ mockAudioPlayerButtonProps.push(props);
98
+
90
99
  return (
91
- <Text testID="create-playlist-button" onPress={onPress}>
92
- {data.title}
100
+ <Text testID="create-playlist-button" onPress={props.onPress}>
101
+ {props.data?.title}
93
102
  </Text>
94
103
  );
95
104
  },
@@ -201,6 +210,74 @@ describe("BottomSheetModalContent role/behavior cells", () => {
201
210
  });
202
211
  });
203
212
 
213
+ it("shows the empty Next Up message only when now playing is on and the list is empty", () => {
214
+ mockNowPlayingHeaderProps.length = 0;
215
+ const { BehaviorSubject } = require("rxjs");
216
+
217
+ const emptyQueue = new ContentViewModel({
218
+ title: "Queue",
219
+ items: [],
220
+ role: "dynamic_collection",
221
+ dynamicCollection: {
222
+ operations: ["nowPlaying", "remove", "reorder", "clearAll"],
223
+ },
224
+ });
225
+
226
+ emptyQueue.editableCollection = {
227
+ items$: new BehaviorSubject([]),
228
+ operations: ["nowPlaying", "remove", "reorder", "clearAll"],
229
+ move: jest.fn(),
230
+ };
231
+
232
+ const { rerender } = render(
233
+ <BottomSheetModalContent
234
+ {...baseProps}
235
+ items={[]}
236
+ emptyMessage="Queued items will be added here"
237
+ viewModel={emptyQueue}
238
+ />
239
+ );
240
+
241
+ expect(mockNowPlayingHeaderProps.at(-1)).toMatchObject({
242
+ isEmpty: true,
243
+ emptyMessage: "Queued items will be added here",
244
+ });
245
+
246
+ const withItem = new ContentViewModel({
247
+ title: "Queue",
248
+ items: [{ id: "t1", title: "Track 1" }],
249
+ role: "dynamic_collection",
250
+ dynamicCollection: {
251
+ operations: ["nowPlaying", "remove", "reorder", "clearAll"],
252
+ },
253
+ });
254
+
255
+ withItem.editableCollection = {
256
+ items$: new BehaviorSubject([{ id: "t1", title: "Track 1" }]),
257
+ operations: ["nowPlaying", "remove", "reorder", "clearAll"],
258
+ move: jest.fn(),
259
+ };
260
+
261
+ rerender(
262
+ <BottomSheetModalContent
263
+ {...baseProps}
264
+ items={[{ id: "t1", title: "Track 1", label: "Track 1" }]}
265
+ emptyMessage="Queued items will be added here"
266
+ viewModel={withItem}
267
+ />
268
+ );
269
+
270
+ expect(mockNowPlayingHeaderProps.at(-1)).toMatchObject({
271
+ isEmpty: false,
272
+ });
273
+
274
+ mockNowPlayingHeaderProps.length = 0;
275
+
276
+ render(<BottomSheetModalContent {...baseProps} />);
277
+
278
+ expect(mockNowPlayingHeaderProps).toHaveLength(0);
279
+ });
280
+
204
281
  it("honors disableScrollViewWrap in contentComponentProps to bypass ScrollView wrapping", () => {
205
282
  const CustomComponent = () => (
206
283
  <React.Fragment>Custom Content</React.Fragment>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applicaster/zapp-react-native-ui-components",
3
- "version": "15.1.15",
3
+ "version": "15.1.16-rc.0",
4
4
  "description": "Applicaster Zapp React Native ui components for the Quick Brick App",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -28,10 +28,10 @@
28
28
  },
29
29
  "homepage": "https://github.com/applicaster/quickbrick#readme",
30
30
  "dependencies": {
31
- "@applicaster/applicaster-types": "15.1.15",
32
- "@applicaster/zapp-react-native-bridge": "15.1.15",
33
- "@applicaster/zapp-react-native-redux": "15.1.15",
34
- "@applicaster/zapp-react-native-utils": "15.1.15",
31
+ "@applicaster/applicaster-types": "15.1.16-rc.0",
32
+ "@applicaster/zapp-react-native-bridge": "15.1.16-rc.0",
33
+ "@applicaster/zapp-react-native-redux": "15.1.16-rc.0",
34
+ "@applicaster/zapp-react-native-utils": "15.1.16-rc.0",
35
35
  "fast-json-stable-stringify": "^2.1.0",
36
36
  "promise": "^8.3.0",
37
37
  "react-native-sortables": "1.7.1",