@applicaster/zapp-react-native-ui-components 16.0.0-rc.97 → 16.0.0-rc.98

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.
@@ -18,11 +18,42 @@ import {
18
18
  View,
19
19
  } from "react-native";
20
20
 
21
+ import { resolveDefaultAssetUri } from "@applicaster/zapp-react-native-utils/actionUtils";
22
+
21
23
  const styles = StyleSheet.create({
22
24
  column: {
23
25
  alignItems: "center",
24
26
  width: "100%",
25
27
  },
28
+ validationMessage: {
29
+ flexDirection: "row",
30
+ alignItems: "center",
31
+ alignSelf: "center",
32
+ overflow: "hidden",
33
+ backgroundColor: "rgba(229,30,37,1.00)",
34
+ borderRadius: 100,
35
+ marginTop: 16,
36
+ paddingLeft: 8,
37
+ paddingRight: 12,
38
+ paddingTop: 8,
39
+ paddingBottom: 8,
40
+ gap: 8,
41
+ },
42
+ validationMessageIcon: {
43
+ width: 20,
44
+ height: 20,
45
+ alignSelf: "flex-start",
46
+ },
47
+ validationMessageText: {
48
+ color: "rgba(255,255,255,1.00)",
49
+ fontFamily: Platform.select({
50
+ ios: "GreycliffCF-Bold",
51
+ android: "Greycliff-Bold",
52
+ default: "Greycliff-Bold",
53
+ }),
54
+ fontSize: 13,
55
+ lineHeight: 20,
56
+ },
26
57
  textInput: {
27
58
  padding: 0,
28
59
  margin: 0,
@@ -51,14 +82,22 @@ export type TextTransform =
51
82
 
52
83
  export type TextAlignment = "left" | "center";
53
84
 
85
+ export type UnderlineConfiguration = {
86
+ focusedBorderColor: string;
87
+ borderColor: string;
88
+ /** Bottom underline height; 0 = none (pill search uses borderWidth instead) */
89
+ underlineHeight: number;
90
+ underlineMarginTop: number;
91
+ /** Radius of the underline bar itself (bottom border style). */
92
+ underlineCornerRadius: number;
93
+ };
94
+
54
95
  export type BackgroundConfiguration = {
55
96
  defaultColor: string;
56
97
  focusedColor: string;
57
98
  borderColor: string;
58
99
  focusedBorderColor: string;
59
100
  borderWidth: number;
60
- /** Bottom underline height; 0 = none (pill search uses borderWidth instead) */
61
- underlineHeight: number;
62
101
  cornerRadius: number;
63
102
  paddingTop: number;
64
103
  paddingRight: number;
@@ -95,6 +134,9 @@ export type TextConfiguration = {
95
134
  androidLetterSpacing: number;
96
135
  textTransform: TextTransform;
97
136
  alignment: TextAlignment;
137
+ /** Vertical spacing around the input text; drives the gap above the underline. */
138
+ marginTop: number;
139
+ marginBottom: number;
98
140
  };
99
141
 
100
142
  export type LeadingIconConfiguration = {
@@ -117,6 +159,7 @@ export type IconsConfiguration = {
117
159
  };
118
160
 
119
161
  export type InputConfiguration = {
162
+ underline: UnderlineConfiguration;
120
163
  background: BackgroundConfiguration;
121
164
  label: LabelConfiguration;
122
165
  text: TextConfiguration;
@@ -129,7 +172,6 @@ export const DEFAULT_BACKGROUND_CONFIGURATION: BackgroundConfiguration = {
129
172
  borderColor: "rgba(255, 255, 255, 0.55)",
130
173
  focusedBorderColor: "rgba(255, 255, 255, 0.85)",
131
174
  borderWidth: 1,
132
- underlineHeight: 0,
133
175
  cornerRadius: 24,
134
176
  paddingTop: 10,
135
177
  paddingRight: 10,
@@ -165,6 +207,8 @@ export const DEFAULT_TEXT_CONFIGURATION: TextConfiguration = {
165
207
  androidLetterSpacing: 0,
166
208
  textTransform: "default",
167
209
  alignment: "left",
210
+ marginTop: 0,
211
+ marginBottom: 0,
168
212
  };
169
213
 
170
214
  export const DEFAULT_ICONS_CONFIGURATION: IconsConfiguration = {
@@ -180,8 +224,17 @@ export const DEFAULT_ICONS_CONFIGURATION: IconsConfiguration = {
180
224
  },
181
225
  };
182
226
 
227
+ export const DEFAULT_UNDERLINE_CONFIGURATION: UnderlineConfiguration = {
228
+ borderColor: "rgba(255, 255, 255, 0.55)",
229
+ focusedBorderColor: "rgba(255, 255, 255, 0.85)",
230
+ underlineHeight: 0,
231
+ underlineMarginTop: 0,
232
+ underlineCornerRadius: 0,
233
+ };
234
+
183
235
  export const DEFAULT_INPUT_CONFIGURATION: InputConfiguration = {
184
236
  background: DEFAULT_BACKGROUND_CONFIGURATION,
237
+ underline: DEFAULT_UNDERLINE_CONFIGURATION,
185
238
  label: DEFAULT_LABEL_CONFIGURATION,
186
239
  text: DEFAULT_TEXT_CONFIGURATION,
187
240
  icons: DEFAULT_ICONS_CONFIGURATION,
@@ -203,6 +256,7 @@ export type ItemProps = {
203
256
  value?: string;
204
257
  focused?: boolean;
205
258
  focusedClearButton?: boolean;
259
+ validationMessage?: string;
206
260
  onChangeText?: (text: string) => void;
207
261
  onClearPress?: () => void;
208
262
  onFocus?: () => void;
@@ -243,7 +297,7 @@ function shouldShowLabel(label: LabelConfiguration, labelText?: string) {
243
297
  }
244
298
 
245
299
  function isUnderlineLayout(configuration: InputConfiguration) {
246
- return configuration.background.underlineHeight > 0;
300
+ return configuration.underline.underlineHeight > 0;
247
301
  }
248
302
 
249
303
  // ---------------------------------------------------------------------------
@@ -323,7 +377,7 @@ function InputContainer({
323
377
  }
324
378
 
325
379
  type InputUnderlineProps = {
326
- configuration: BackgroundConfiguration;
380
+ configuration: UnderlineConfiguration;
327
381
  focused: boolean;
328
382
  };
329
383
 
@@ -337,7 +391,9 @@ function InputUnderline({ configuration, focused }: InputUnderlineProps) {
337
391
  style={[
338
392
  styles.underline,
339
393
  {
394
+ marginTop: configuration.underlineMarginTop,
340
395
  height: configuration.underlineHeight,
396
+ borderRadius: configuration.underlineCornerRadius,
341
397
  backgroundColor: focused
342
398
  ? configuration.focusedBorderColor
343
399
  : configuration.borderColor,
@@ -427,6 +483,8 @@ function InputField({
427
483
  }),
428
484
  textTransform: getTextTransformStyle(configuration.textTransform),
429
485
  textAlign: configuration.alignment,
486
+ marginTop: configuration.marginTop,
487
+ marginBottom: configuration.marginBottom,
430
488
  },
431
489
  ]}
432
490
  />
@@ -479,12 +537,13 @@ export function AudioPlayerInput({
479
537
  value = "",
480
538
  focused = false,
481
539
  focusedClearButton = false,
540
+ validationMessage,
482
541
  onChangeText,
483
542
  onClearPress,
484
543
  onFocus,
485
544
  onBlur,
486
545
  }: ItemProps) {
487
- const { background, label, text, icons } = configuration;
546
+ const { background, label, text, icons, underline } = configuration;
488
547
  const inputRef = useRef<TextInputRef>(null);
489
548
  const underlineLayout = isUnderlineLayout(configuration);
490
549
  const showLabel = shouldShowLabel(label, data.label);
@@ -545,21 +604,40 @@ export function AudioPlayerInput({
545
604
  </>
546
605
  ) : null}
547
606
  {underlineLayout ? (
548
- <InputUnderline configuration={background} focused={focused} />
607
+ <InputUnderline configuration={underline} focused={focused} />
549
608
  ) : null}
550
609
  </InputContainer>
551
610
  </Pressable>
552
611
  );
553
612
 
554
- if (!showLabel || !data.label) {
555
- return inputRow;
556
- }
557
-
558
613
  return (
559
614
  <View style={styles.column}>
560
- <InputLabel text={data.label} configuration={label} />
561
- <View style={{ height: background.verticalGutter }} />
615
+ {showLabel && data.label ? (
616
+ <>
617
+ <InputLabel text={data.label} configuration={label} />
618
+ <View style={{ height: background.verticalGutter }} />
619
+ </>
620
+ ) : null}
562
621
  {inputRow}
622
+ {validationMessage ? (
623
+ <View style={styles.validationMessage} accessibilityRole="alert">
624
+ <Image
625
+ source={{
626
+ uri: resolveDefaultAssetUri(
627
+ require("../../../../assets/empty-Info.png")
628
+ ),
629
+ }}
630
+ style={styles.validationMessageIcon}
631
+ />
632
+ <Text
633
+ style={styles.validationMessageText}
634
+ numberOfLines={1}
635
+ ellipsizeMode="tail"
636
+ >
637
+ {validationMessage}
638
+ </Text>
639
+ </View>
640
+ ) : null}
563
641
  </View>
564
642
  );
565
643
  }
@@ -572,55 +650,77 @@ export function AudioPlayerInput({
572
650
  export const VARIANT_SEARCH_INPUT: InputConfiguration =
573
651
  DEFAULT_INPUT_CONFIGURATION;
574
652
 
575
- /** Name playlist — centered title + label + underline (screenshot) */
653
+ /**
654
+ * Name playlist — centered title + 32pt text over a rounded bottom underline.
655
+ *
656
+ * Values mirror the manifest configuration the
657
+ * `quick-brick-component-text-field` plugin renders from; the comment on each
658
+ * line names the reference key it comes from.
659
+ */
576
660
  export const VARIANT_NAME_PLAYLIST_INPUT: InputConfiguration = {
577
661
  background: {
578
- defaultColor: "transparent",
579
- focusedColor: "transparent",
580
- borderColor: "rgba(255, 255, 255, 0.35)",
581
- focusedBorderColor: "rgba(255, 255, 255, 0.55)",
662
+ // text_input_background_*_background_color
663
+ defaultColor: "rgba(30,30,30,0.00)",
664
+ focusedColor: "rgba(46,46,46,0.00)",
665
+ // text_input_style_border_*_border_color
666
+ borderColor: "rgba(255,255,255,0.25)",
667
+ focusedBorderColor: "rgba(255,255,255,1.00)",
668
+ // border_border_style is "bottom", so the box border is off and the
669
+ // underline below draws the border instead.
582
670
  borderWidth: 0,
583
- underlineHeight: 1,
584
671
  cornerRadius: 0,
672
+ // text_input_style_input_padding_*
585
673
  paddingTop: 0,
586
674
  paddingRight: 0,
587
- paddingBottom: 10,
675
+ paddingBottom: 0,
588
676
  paddingLeft: 0,
589
- horizontalGutter: 0,
590
- verticalGutter: 16,
591
- height: 0,
677
+ horizontalGutter: 0, // text_input_style_input_horizontal_gutter
678
+ verticalGutter: 0, // component_style_vertical_gutter
679
+ height: 0, // hug content
680
+ },
681
+ underline: {
682
+ borderColor: "rgba(255,255,255,0.25)",
683
+ focusedBorderColor: "rgba(255,255,255,1.00)",
684
+ underlineHeight: 2, // text_input_style_border_border_size
685
+ // The gap above the bar comes from the text's marginBottom, matching how
686
+ // the plugin applies `text_label_margin_bottom` to the input itself.
687
+ underlineMarginTop: 0,
688
+ underlineCornerRadius: 4, // ..._border_bottom_border_corner_radius
592
689
  },
593
690
  label: {
594
- enabled: true,
595
- fontColor: "rgba(142, 142, 147, 1)",
596
- iosFontFamily: "SFProText-Regular",
597
- androidFontFamily: "Roboto",
598
- fontSize: 15,
599
- lineHeight: 20,
600
- iosLetterSpacing: -0.2,
691
+ enabled: true, // title_enable
692
+ fontColor: "rgba(255,255,255,0.50)", // title_style_color
693
+ iosFontFamily: "GreycliffCF-Bold", // title_style_ios_font_family
694
+ androidFontFamily: "Greycliff-Bold", // title_style_android_font_family
695
+ fontSize: 14, // title_style_font_size
696
+ lineHeight: 32, // title_style_line_height
697
+ iosLetterSpacing: 0,
601
698
  androidLetterSpacing: 0,
602
- textTransform: "default",
699
+ textTransform: "default", // title_style_text_transform: "none"
603
700
  },
604
701
  text: {
605
- fontColor: "#FFFFFF",
606
- focusedFontColor: "#FFFFFF",
607
- placeholderColor: "rgba(142, 142, 147, 1)",
608
- cursorColor: "#FFFFFF",
609
- iosFontFamily: "SFProText-Bold",
610
- androidFontFamily: "Roboto-Bold",
702
+ // text_input_style_text_label_*
703
+ fontColor: "rgba(255,255,255,1.00)", // active_label_color
704
+ focusedFontColor: "rgba(255,255,255,1.00)",
705
+ placeholderColor: "rgba(255,255,255,0.50)", // placeholder_label_color
706
+ cursorColor: "rgba(249,183,72,1.00)", // text_input_style_cursor_cursor_color
707
+ iosFontFamily: "GreycliffCF-Bold",
708
+ androidFontFamily: "Greycliff-Bold",
611
709
  fontSize: 32,
612
710
  lineHeight: 40,
613
- iosLetterSpacing: -0.6,
711
+ iosLetterSpacing: 0,
614
712
  androidLetterSpacing: 0,
615
713
  textTransform: "default",
616
714
  alignment: "center",
715
+ marginTop: 20, // text_label_margin_top
716
+ marginBottom: 20, // text_label_margin_bottom
617
717
  },
618
718
  icons: {
619
719
  width: 20,
620
720
  height: 20,
621
721
  leadingIcon: { enabled: false },
622
722
  trailingIcon: {
623
- enabled: false,
723
+ enabled: false, // clear_button_enable
624
724
  backgroundColor: "rgba(58, 58, 60, 1)",
625
725
  focusedBackgroundColor: "rgba(72, 72, 74, 1)",
626
726
  },
@@ -1,5 +1,5 @@
1
- import React from "react";
2
- import { View } from "react-native";
1
+ import React, { useEffect, useState } from "react";
2
+ import { StyleSheet, Text, View } from "react-native";
3
3
  import {
4
4
  AudioPlayerTrackItem,
5
5
  VARIANT_NOW_PLAYING,
@@ -7,13 +7,57 @@ import {
7
7
  } from "./Item";
8
8
  import { AudioPlayerHeader, VARIANT_SECTION_HEADER } from "./Header";
9
9
  import type { MenuItem } from "@applicaster/zapp-react-native-utils/modalState/types";
10
- import { usePlayer } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayer";
11
- import { usePlayerState } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayerState";
10
+ import { playerManager } from "@applicaster/zapp-react-native-utils/appUtils/playerManager";
11
+ import { PlayerRole } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/const";
12
+ import type { Player } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/player";
13
+
14
+ const DEFAULT_EMPTY_MESSAGE = "Queued items will be added here";
15
+
16
+ const styles = StyleSheet.create({
17
+ divider: {
18
+ height: StyleSheet.hairlineWidth,
19
+ backgroundColor: "rgba(255, 255, 255, 0.1)",
20
+ marginHorizontal: 20,
21
+ marginVertical: 8,
22
+ },
23
+ emptyMessage: {
24
+ paddingTop: 32,
25
+ paddingBottom: 32,
26
+ paddingHorizontal: 40,
27
+ textAlign: "center",
28
+ color: "#999999",
29
+ fontSize: 13,
30
+ lineHeight: 16,
31
+ },
32
+ });
33
+
34
+ const isPrimaryOrCastingPlayer = (player?: Player | null): boolean => {
35
+ if (!player) {
36
+ return false;
37
+ }
38
+
39
+ return (
40
+ player.playerRole === PlayerRole.Primary ||
41
+ player.playerRole === PlayerRole.Chromecast
42
+ );
43
+ };
44
+
45
+ const getTargetPlayer = (): Player | null => {
46
+ if (playerManager.isCasting()) {
47
+ return playerManager.getCastingReceiver() || null;
48
+ }
49
+
50
+ const active = playerManager.getActivePlayer();
51
+
52
+ return isPrimaryOrCastingPlayer(active) ? active : null;
53
+ };
12
54
 
13
55
  type NowPlayingHeaderSectionProps = {
14
56
  onClear?: () => void;
15
57
  nextUpTitle?: string;
16
58
  nowPlayingTitle?: string;
59
+ isEmpty?: boolean;
60
+ emptyMessage?: string;
17
61
  clearLabel?: string;
18
62
  };
19
63
 
@@ -21,20 +65,104 @@ export function NowPlayingHeaderSection({
21
65
  onClear,
22
66
  nextUpTitle = "Next Up",
23
67
  nowPlayingTitle = "Now Playing",
68
+ isEmpty = false,
69
+ emptyMessage = DEFAULT_EMPTY_MESSAGE,
24
70
  clearLabel,
25
71
  }: NowPlayingHeaderSectionProps) {
26
- const player = usePlayer();
27
- const playerState = usePlayerState("queue-now-playing-section");
72
+ const [activePlayer, setActivePlayer] = useState<Player | null>(
73
+ getTargetPlayer
74
+ );
75
+
76
+ const [currentEntry, setCurrentEntry] = useState<ZappEntry | null>(
77
+ () => getTargetPlayer()?.entry || null
78
+ );
79
+
80
+ const [isPaused, setIsPaused] = useState<boolean>(() =>
81
+ Boolean(getTargetPlayer()?.isPaused())
82
+ );
83
+
84
+ useEffect(() => {
85
+ const handleActivePlayerUpdate = () => {
86
+ const player = getTargetPlayer();
87
+
88
+ if (player) {
89
+ setActivePlayer(player);
90
+
91
+ if (player.entry) {
92
+ setCurrentEntry(player.entry);
93
+ }
94
+
95
+ setIsPaused(Boolean(player.isPaused()));
96
+ }
97
+ };
98
+
99
+ handleActivePlayerUpdate();
100
+
101
+ const removeLifecycle = playerManager.addLifecycleListener({
102
+ onRegistered: (registeredPlayer) => {
103
+ if (isPrimaryOrCastingPlayer(registeredPlayer)) {
104
+ handleActivePlayerUpdate();
105
+ }
106
+ },
107
+ onCastingChanged: handleActivePlayerUpdate,
108
+ });
109
+
110
+ return () => {
111
+ removeLifecycle?.();
112
+ };
113
+ }, []);
114
+
115
+ useEffect(() => {
116
+ if (!activePlayer) {
117
+ return;
118
+ }
119
+
120
+ const syncPlayerState = () => {
121
+ if (activePlayer.entry) {
122
+ setCurrentEntry(activePlayer.entry);
123
+ }
124
+
125
+ setIsPaused(Boolean(activePlayer.isPaused()));
126
+ };
127
+
128
+ syncPlayerState();
129
+
130
+ const removeListener = activePlayer.addListener({
131
+ id: "queue-now-playing-header",
132
+ listener: {
133
+ onPlayerPause: () => setIsPaused(true),
134
+ onPlayerResume: () => setIsPaused(false),
135
+ onVideoLoad: syncPlayerState,
136
+ onLoad: syncPlayerState,
137
+ },
138
+ });
139
+
140
+ return () => {
141
+ removeListener?.();
142
+ };
143
+ }, [activePlayer]);
144
+
145
+ const handlePlayPause = () => {
146
+ if (!activePlayer) {
147
+ return;
148
+ }
149
+
150
+ if (isPaused) {
151
+ activePlayer.play();
152
+ setIsPaused(false);
153
+ } else {
154
+ activePlayer.pause();
155
+ setIsPaused(true);
156
+ }
157
+ };
28
158
 
29
159
  const renderNowPlayingTrack = () => {
30
- if (!player?.entry) {
160
+ if (!currentEntry) {
31
161
  return null;
32
162
  }
33
163
 
34
- const mediaGroup = player.entry.media_group;
35
-
36
- const imageGroup = Array.isArray(mediaGroup)
37
- ? mediaGroup.find((g: any) => g.type === "image")
164
+ const imageGroup = Array.isArray(currentEntry.media_group)
165
+ ? currentEntry.media_group.find((g: any) => g.type === "image")
38
166
  : null;
39
167
 
40
168
  const firstImage = Array.isArray(imageGroup?.media_item)
@@ -42,26 +170,18 @@ export function NowPlayingHeaderSection({
42
170
  : imageGroup?.media_item?.src;
43
171
 
44
172
  const nowPlayingItem: MenuItem = {
45
- id: String(player.entry.id ?? "now_playing"),
46
- title: (player.entry.title as string) || String(player.entry.id),
47
- summary: (player.entry.summary as string) ?? "",
173
+ id: String(currentEntry.id ?? "now_playing"),
174
+ title: (currentEntry.title as string) || String(currentEntry.id),
175
+ summary: (currentEntry.summary as string) ?? "",
48
176
  icon: firstImage,
49
177
  };
50
178
 
51
179
  const data = mapMenuItemToNowPlayingData(
52
180
  nowPlayingItem,
53
- playerState?.isPaused,
181
+ isPaused,
54
182
  nowPlayingTitle
55
183
  );
56
184
 
57
- const handlePlayPause = () => {
58
- if (playerState?.isPaused) {
59
- player?.play();
60
- } else {
61
- player?.pause();
62
- }
63
- };
64
-
65
185
  return (
66
186
  <AudioPlayerTrackItem
67
187
  configuration={VARIANT_NOW_PLAYING}
@@ -79,6 +199,7 @@ export function NowPlayingHeaderSection({
79
199
  data={{ title: nextUpTitle, clearLabel }}
80
200
  onClear={onClear}
81
201
  />
202
+ {isEmpty ? <Text style={styles.emptyMessage}>{emptyMessage}</Text> : null}
82
203
  </View>
83
204
  );
84
205
  }
@@ -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
+ });
@@ -92,6 +92,8 @@ type ModalComponentProps = {
92
92
  contentComponentProps?: Record<string, any>;
93
93
  styleOverrides?: ModalBlocksConfig | Record<string, any>;
94
94
  measurementPhase?: boolean;
95
+ /** Copy under Next Up when the queue has no upcoming items. */
96
+ emptyMessage?: string;
95
97
  };
96
98
 
97
99
  function _BottomSheetModalContent(props: ModalComponentProps) {
@@ -115,6 +117,7 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
115
117
  contentComponentProps = {},
116
118
  styleOverrides: propStyleOverrides,
117
119
  measurementPhase = false,
120
+ emptyMessage,
118
121
  } = props;
119
122
 
120
123
  const route = useRef(currentRoute);
@@ -385,6 +388,7 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
385
388
  const hasNowPlaying = hasOperation(operations, OPERATIONS.NOW_PLAYING);
386
389
  const hasClearAll = hasOperation(operations, OPERATIONS.CLEAR_ALL);
387
390
  const hasAdd = hasOperation(operations, OPERATIONS.ADD);
391
+ const isQueueEmpty = hasNowPlaying && (itemsToRender?.length ?? 0) === 0;
388
392
 
389
393
  const handleDeleteCollection = useCallback(() => {
390
394
  editableCollection?.deleteCollection?.();
@@ -409,6 +413,8 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
409
413
  onClear={hasClearAll ? handleDeleteCollection : undefined}
410
414
  nextUpTitle={nextUpTitle}
411
415
  nowPlayingTitle={nowPlayingTitle}
416
+ isEmpty={isQueueEmpty}
417
+ emptyMessage={emptyMessage}
412
418
  clearLabel={clearLabel}
413
419
  />
414
420
  ),
@@ -435,6 +441,8 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
435
441
  handleDeleteCollection,
436
442
  nextUpTitle,
437
443
  nowPlayingTitle,
444
+ isQueueEmpty,
445
+ emptyMessage,
438
446
  clearLabel,
439
447
  hasAdd,
440
448
  handleCreatePlaylist,
@@ -545,6 +553,8 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
545
553
  onClear={hasClearAll ? handleDeleteCollection : undefined}
546
554
  nextUpTitle={nextUpTitle}
547
555
  nowPlayingTitle={nowPlayingTitle}
556
+ isEmpty={isQueueEmpty}
557
+ emptyMessage={emptyMessage}
548
558
  clearLabel={clearLabel}
549
559
  />
550
560
  ) : null}
@@ -9,6 +9,7 @@ import {
9
9
  AudioPlayerButton,
10
10
  VARIANT_TEXT_ONLY,
11
11
  } from "./AudioPlayer/Components/Button";
12
+ import { logger } from "@applicaster/zapp-react-native-utils/logger";
12
13
 
13
14
  export type TextInputContentProps = {
14
15
  /** Injected by the sheet host; unused for layout (header is owned by ModalComponent). */
@@ -19,17 +20,25 @@ export type TextInputContentProps = {
19
20
  inputLabel?: string;
20
21
  defaultValue?: string;
21
22
  buttonLabel: string;
23
+ validationLabel?: string;
24
+ maxLength?: number;
22
25
  actions: ActionType[];
23
26
  actionContext?: Record<string, any>;
24
27
  };
25
28
 
29
+ // Spacing mirrors the `component_style_*` keys in text input component.
26
30
  const styles = StyleSheet.create({
27
31
  container: {
28
- paddingHorizontal: 20,
29
- paddingBottom: 8,
32
+ backgroundColor: "transparent", // component_style_background_color
33
+ paddingLeft: 40, // component_style_padding_left
34
+ paddingRight: 40, // component_style_padding_right
35
+ paddingTop: 0, // component_style_padding_top
36
+ paddingBottom: 0, // component_style_padding_bottom
37
+ marginTop: 20, // component_style_margin_top
38
+ marginBottom: 20, // component_style_margin_bottom
30
39
  },
31
40
  inputWrap: {
32
- marginTop: 8,
41
+ // Gap down to the submit button; owned by the modal, not the reference.
33
42
  marginBottom: 24,
34
43
  },
35
44
  });
@@ -45,11 +54,24 @@ export function TextInputContent(props: TextInputContentProps) {
45
54
  buttonLabel,
46
55
  actions,
47
56
  actionContext,
57
+ maxLength = 255,
58
+ validationLabel,
48
59
  } = props;
49
60
 
50
61
  const [textValue, setTextValue] = useState(defaultValue);
51
62
  const [submitting, setSubmitting] = useState(false);
52
63
 
64
+ const validationMessage =
65
+ textValue.length > maxLength
66
+ ? validationLabel || "Too many characters"
67
+ : undefined;
68
+
69
+ if (!validationLabel) {
70
+ logger.warn(
71
+ "Modal/TextInputContent: Validation label (validationLabel) is not provided, using default message 'Too many characters'."
72
+ );
73
+ }
74
+
53
75
  const onSubmit = useCallback(async () => {
54
76
  if (submitting) {
55
77
  return;
@@ -97,6 +119,7 @@ export function TextInputContent(props: TextInputContentProps) {
97
119
  label: inputLabel,
98
120
  }}
99
121
  value={textValue}
122
+ validationMessage={validationMessage}
100
123
  onChangeText={setTextValue}
101
124
  onClearPress={() => setTextValue("")}
102
125
  />
@@ -105,7 +128,7 @@ export function TextInputContent(props: TextInputContentProps) {
105
128
  configuration={VARIANT_TEXT_ONLY}
106
129
  data={{ title: buttonLabel }}
107
130
  onPress={onSubmit}
108
- disabled={submitting}
131
+ disabled={!!validationMessage || submitting}
109
132
  />
110
133
  </View>
111
134
  );
@@ -83,9 +83,14 @@ jest.mock("../AudioPlayer/Components/Item", () => ({
83
83
  }));
84
84
 
85
85
  const mockAudioPlayerButtonProps: Array<any> = [];
86
+ const mockNowPlayingHeaderProps: Array<any> = [];
86
87
 
87
88
  jest.mock("../AudioPlayer/Components", () => ({
88
- NowPlayingHeaderSection: () => null,
89
+ NowPlayingHeaderSection: (props: any) => {
90
+ mockNowPlayingHeaderProps.push(props);
91
+
92
+ return null;
93
+ },
89
94
  AudioPlayerButton: (props: any) => {
90
95
  const { Text } = require("react-native");
91
96
 
@@ -216,6 +221,74 @@ describe("BottomSheetModalContent role/behavior cells", () => {
216
221
  });
217
222
  });
218
223
 
224
+ it("shows the empty Next Up message only when now playing is on and the list is empty", () => {
225
+ mockNowPlayingHeaderProps.length = 0;
226
+ const { BehaviorSubject } = require("rxjs");
227
+
228
+ const emptyQueue = new ContentViewModel({
229
+ title: "Queue",
230
+ items: [],
231
+ role: "dynamic_collection",
232
+ dynamicCollection: {
233
+ operations: ["nowPlaying", "remove", "reorder", "clearAll"],
234
+ },
235
+ });
236
+
237
+ emptyQueue.editableCollection = {
238
+ items$: new BehaviorSubject([]),
239
+ operations: ["nowPlaying", "remove", "reorder", "clearAll"],
240
+ move: jest.fn(),
241
+ };
242
+
243
+ const { rerender } = render(
244
+ <BottomSheetModalContent
245
+ {...baseProps}
246
+ items={[]}
247
+ emptyMessage="Queued items will be added here"
248
+ viewModel={emptyQueue}
249
+ />
250
+ );
251
+
252
+ expect(mockNowPlayingHeaderProps.at(-1)).toMatchObject({
253
+ isEmpty: true,
254
+ emptyMessage: "Queued items will be added here",
255
+ });
256
+
257
+ const withItem = new ContentViewModel({
258
+ title: "Queue",
259
+ items: [{ id: "t1", title: "Track 1" }],
260
+ role: "dynamic_collection",
261
+ dynamicCollection: {
262
+ operations: ["nowPlaying", "remove", "reorder", "clearAll"],
263
+ },
264
+ });
265
+
266
+ withItem.editableCollection = {
267
+ items$: new BehaviorSubject([{ id: "t1", title: "Track 1" }]),
268
+ operations: ["nowPlaying", "remove", "reorder", "clearAll"],
269
+ move: jest.fn(),
270
+ };
271
+
272
+ rerender(
273
+ <BottomSheetModalContent
274
+ {...baseProps}
275
+ items={[{ id: "t1", title: "Track 1", label: "Track 1" }]}
276
+ emptyMessage="Queued items will be added here"
277
+ viewModel={withItem}
278
+ />
279
+ );
280
+
281
+ expect(mockNowPlayingHeaderProps.at(-1)).toMatchObject({
282
+ isEmpty: false,
283
+ });
284
+
285
+ mockNowPlayingHeaderProps.length = 0;
286
+
287
+ render(<BottomSheetModalContent {...baseProps} />);
288
+
289
+ expect(mockNowPlayingHeaderProps).toHaveLength(0);
290
+ });
291
+
219
292
  it("honors disableScrollViewWrap in contentComponentProps to bypass ScrollView wrapping", () => {
220
293
  const CustomComponent = () => (
221
294
  <React.Fragment>Custom Content</React.Fragment>
@@ -19,7 +19,7 @@ jest.mock(
19
19
  );
20
20
 
21
21
  jest.mock("../AudioPlayer/Components/Input", () => ({
22
- AudioPlayerInput: ({ value, onChangeText, data }: any) => {
22
+ AudioPlayerInput: ({ value, onChangeText, data, validationMessage }: any) => {
23
23
  const { TextInput, Text } = require("react-native");
24
24
 
25
25
  return (
@@ -31,6 +31,9 @@ jest.mock("../AudioPlayer/Components/Input", () => ({
31
31
  onChangeText={onChangeText}
32
32
  placeholder={data?.placeholder}
33
33
  />
34
+ {validationMessage ? (
35
+ <Text testID="input-validation-message">{validationMessage}</Text>
36
+ ) : null}
34
37
  </>
35
38
  );
36
39
  },
@@ -87,6 +90,16 @@ describe("TextInputContent", () => {
87
90
  expect(screen.getByText("Update")).toBeTruthy();
88
91
  });
89
92
 
93
+ it("shows validation below the input when its value is too long", () => {
94
+ render(<TextInputContent maxLength={100} {...baseProps} />);
95
+
96
+ fireEvent.changeText(screen.getByTestId("playlist-input"), "x".repeat(101));
97
+
98
+ expect(screen.getByTestId("input-validation-message")).toHaveTextContent(
99
+ "Too many characters"
100
+ );
101
+ });
102
+
90
103
  it("on submit merges name into sendCloudEvent data then dismisses", async () => {
91
104
  render(<TextInputContent {...baseProps} />);
92
105
 
@@ -13,8 +13,15 @@ export async function showTextInputAction(
13
13
  action: ActionType,
14
14
  context?: Record<string, any>
15
15
  ): Promise<ActionResult> {
16
- const { headerTitle, inputLabel, defaultValue, buttonLabel, actions } =
17
- action.options || {};
16
+ const {
17
+ headerTitle,
18
+ inputLabel,
19
+ defaultValue,
20
+ buttonLabel,
21
+ validationLabel,
22
+ actions,
23
+ maxLength,
24
+ } = action.options || {};
18
25
 
19
26
  if (!headerTitle || !buttonLabel || !actions || !actions.length) {
20
27
  return ActionResult.Error;
@@ -30,10 +37,12 @@ export async function showTextInputAction(
30
37
  content: {
31
38
  component: TextInputContent,
32
39
  props: {
40
+ maxLength,
33
41
  inputLabel,
34
42
  defaultValue: defaultValue ?? "",
35
43
  buttonLabel,
36
44
  actions,
45
+ validationLabel,
37
46
  },
38
47
  },
39
48
  },
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applicaster/zapp-react-native-ui-components",
3
- "version": "16.0.0-rc.97",
3
+ "version": "16.0.0-rc.98",
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": "16.0.0-rc.97",
32
- "@applicaster/zapp-react-native-bridge": "16.0.0-rc.97",
33
- "@applicaster/zapp-react-native-redux": "16.0.0-rc.97",
34
- "@applicaster/zapp-react-native-utils": "16.0.0-rc.97",
31
+ "@applicaster/applicaster-types": "16.0.0-rc.98",
32
+ "@applicaster/zapp-react-native-bridge": "16.0.0-rc.98",
33
+ "@applicaster/zapp-react-native-redux": "16.0.0-rc.98",
34
+ "@applicaster/zapp-react-native-utils": "16.0.0-rc.98",
35
35
  "fast-json-stable-stringify": "^2.1.0",
36
36
  "promise": "^8.3.0",
37
37
  "react-native-sortables": "1.7.1",