@windstream/react-shared-components 0.2.81 → 0.2.83

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.
@@ -17,6 +17,18 @@ export type ScheduledMessageDay =
17
17
  | "FRI"
18
18
  | "SAT";
19
19
 
20
+ // Known banner colors keep editor autocomplete, while `(string & {})` lets any
21
+ // new color added in Contentful flow through without changing this type.
22
+ export type AnchoredBannerBackgroundColor =
23
+ | "navy"
24
+ | "yellow"
25
+ | "green"
26
+ | "purple"
27
+ | "blue"
28
+ | "white"
29
+ // eslint-disable-next-line @typescript-eslint/ban-types
30
+ | (string & {});
31
+
20
32
  export interface ScheduledMessage {
21
33
  // Stable id for analytics (Contentful entry id or title).
22
34
  id?: string;
@@ -41,7 +53,7 @@ export interface ScheduledMessage {
41
53
 
42
54
  export interface AnchoredBottomBannerProps {
43
55
  ctaSuffixText?: string;
44
- backgroundColor?: "navy" | "yellow" | "green" | "purple" | "blue" | "white";
56
+ backgroundColor?: AnchoredBannerBackgroundColor;
45
57
  iconName?: string;
46
58
  boxShadow?: boolean;
47
59
  ctaButtonLabel?: string;
@@ -54,6 +66,11 @@ export interface AnchoredBottomBannerProps {
54
66
  // When a message is active for today, it drives the countdown + CTA content;
55
67
  // otherwise the banner falls back to its own top-level values above.
56
68
  scheduledMessages?: ScheduledMessage[];
69
+ // Server-generated timestamp (epoch ms) so SSR and the first client render
70
+ // resolve the same schedule/countdown state (no structural hydration
71
+ // mismatch). Reconciled to the live clock after mount. Falls back to
72
+ // Date.now() when omitted.
73
+ initialNowMs?: number;
57
74
  // Fires with the resolved result so consumers (analytics) know what showed.
58
75
  onResolved?: (result: {
59
76
  hasActiveMessage: boolean;
@@ -13,7 +13,7 @@ const meta: Meta<typeof AwardMarquee> = {
13
13
  page: DocsPage,
14
14
  description: {
15
15
  component:
16
- "Server-rendered marquee of award badges. Optional `heading` and `footerText` display gold text above and below the awards, with divider lines around the heading. Animates when `scroll` is set; with 3 or fewer items it animates on mobile and falls back to a static row at the `sm` breakpoint (CSS-driven, no client-side JS).",
16
+ "Server-rendered marquee of award badges. Optional `heading` and `footerText` display gold text above and below the awards, with divider lines around the heading. A single-entry static display can optionally swap in a distinct `mobileIconUrl` below the `sm` breakpoint. Animates when `scroll` is set; with 3 or fewer items it animates on mobile and falls back to a static row at the `sm` breakpoint (CSS-driven, no client-side JS).",
17
17
  },
18
18
  },
19
19
  },
@@ -56,3 +56,47 @@ export const WithHeadingAndFooter: Story = {
56
56
  scroll: false,
57
57
  },
58
58
  };
59
+
60
+ export const SingleConsolidatedLogo: Story = {
61
+ args: {
62
+ heading: "As seen on",
63
+ scroll: false,
64
+ items: [
65
+ {
66
+ iconUrl: "https://placehold.co/1441x622?text=Consolidated+Logo",
67
+ iconAlt: "As seen on logo collection",
68
+ },
69
+ ],
70
+ },
71
+ parameters: {
72
+ docs: {
73
+ description: {
74
+ story:
75
+ "A single consolidated logo entry (no title/description) renders at up to 1100px wide, scales down responsively, and is horizontally centered. The caption block is omitted entirely since there is no caption text.",
76
+ },
77
+ },
78
+ },
79
+ };
80
+
81
+ export const SingleLogoWithMobileImage: Story = {
82
+ args: {
83
+ heading: "As seen on",
84
+ scroll: false,
85
+ items: [
86
+ {
87
+ iconUrl: "https://placehold.co/1441x622?text=Desktop+Logo",
88
+ iconAlt: "As seen on logo collection (desktop)",
89
+ mobileIconUrl: "https://placehold.co/622x622?text=Mobile+Logo",
90
+ mobileIconAlt: "As seen on logo collection (mobile)",
91
+ },
92
+ ],
93
+ },
94
+ parameters: {
95
+ docs: {
96
+ description: {
97
+ story:
98
+ "A single consolidated logo entry can optionally supply `mobileIconUrl` (plus matching `mobileIconAlt`/`mobileIconWidth`/`mobileIconHeight`) to swap in a different, often more vertical, crop below the `sm` (640px) breakpoint. Resize the viewport to see the swap. If `mobileIconUrl` is omitted, the desktop image is used at every breakpoint. This swap only applies when the marquee has a single entry and `scroll` is `false`.",
99
+ },
100
+ },
101
+ },
102
+ };
@@ -92,17 +92,128 @@ describe("AwardMarquee", () => {
92
92
  expect(icon.src).toContain("icon-0.png");
93
93
  });
94
94
 
95
- it("renders intrinsic icon dimensions when provided", () => {
96
- const [item] = buildItems(1);
97
- render(
98
- <AwardMarquee items={[{ ...item, iconWidth: 320, iconHeight: 180 }]} />
99
- );
95
+ it("renders intrinsic icon dimensions when provided (multi-entry, unchanged sizing)", () => {
96
+ const items = buildItems(2).map(item => ({
97
+ ...item,
98
+ iconWidth: 320,
99
+ iconHeight: 180,
100
+ }));
101
+ render(<AwardMarquee items={items} />);
100
102
  const icon = screen.getByAltText("Award 0");
101
103
  expect(icon).toHaveAttribute("width", "320");
102
104
  expect(icon).toHaveAttribute("height", "180");
103
105
  expect(icon).toHaveClass("w-[200px]");
104
106
  });
105
107
 
108
+ it("renders intrinsic icon dimensions when provided (single static entry)", () => {
109
+ const [item] = buildItems(1);
110
+ render(
111
+ <AwardMarquee items={[{ ...item, iconWidth: 1441, iconHeight: 622 }]} />
112
+ );
113
+ const icon = screen.getByAltText("Award 0");
114
+ expect(icon).toHaveAttribute("width", "1441");
115
+ expect(icon).toHaveAttribute("height", "622");
116
+ expect(icon).toHaveClass("w-full", "max-w-[1100px]", "h-auto");
117
+ expect(icon).not.toHaveClass("w-[200px]");
118
+ });
119
+
120
+ it("renders a distinct mobile image for a single static entry when one is supplied", () => {
121
+ const { container } = render(
122
+ <AwardMarquee
123
+ items={[
124
+ {
125
+ iconUrl: "https://example.com/desktop-logo.png",
126
+ iconAlt: "Desktop Logo",
127
+ iconWidth: 1441,
128
+ iconHeight: 622,
129
+ mobileIconUrl: "https://example.com/mobile-logo.png",
130
+ mobileIconAlt: "Mobile Logo",
131
+ mobileIconWidth: 600,
132
+ mobileIconHeight: 400,
133
+ },
134
+ ]}
135
+ scroll={false}
136
+ />
137
+ );
138
+
139
+ const mobileIcon = screen.getByAltText("Mobile Logo") as HTMLImageElement;
140
+ const desktopIcon = screen.getByAltText("Desktop Logo") as HTMLImageElement;
141
+
142
+ expect(mobileIcon.src).toContain("mobile-logo.png");
143
+ expect(mobileIcon).toHaveAttribute("width", "600");
144
+ expect(mobileIcon).toHaveAttribute("height", "400");
145
+ expect(mobileIcon).toHaveClass("sm:hidden", "max-w-[1100px]", "h-auto");
146
+
147
+ expect(desktopIcon.src).toContain("desktop-logo.png");
148
+ expect(desktopIcon).toHaveAttribute("width", "1441");
149
+ expect(desktopIcon).toHaveAttribute("height", "622");
150
+ expect(desktopIcon).toHaveClass(
151
+ "hidden",
152
+ "sm:block",
153
+ "max-w-[1100px]",
154
+ "h-auto"
155
+ );
156
+
157
+ // Exactly one wrapper div (not duplicated) still holds both images.
158
+ expect(
159
+ container.querySelectorAll("#Award-Marquee > div > div")
160
+ ).toHaveLength(1);
161
+ });
162
+
163
+ it("falls back to the desktop image at every breakpoint when no mobile image is supplied", () => {
164
+ render(
165
+ <AwardMarquee
166
+ items={[
167
+ {
168
+ iconUrl: "https://example.com/desktop-logo.png",
169
+ iconAlt: "Desktop Logo",
170
+ },
171
+ ]}
172
+ scroll={false}
173
+ />
174
+ );
175
+
176
+ const icon = screen.getByAltText("Desktop Logo");
177
+ expect(icon).not.toHaveClass("sm:hidden");
178
+ expect(icon).not.toHaveClass("hidden", "sm:block");
179
+ expect(screen.queryAllByAltText("Desktop Logo")).toHaveLength(1);
180
+ });
181
+
182
+ it("ignores the mobile image field for multi-entry and animated-carousel rendering", () => {
183
+ const itemWithMobile = {
184
+ iconUrl: "https://example.com/desktop-logo.png",
185
+ iconAlt: "Desktop Logo",
186
+ mobileIconUrl: "https://example.com/mobile-logo.png",
187
+ mobileIconAlt: "Mobile Logo",
188
+ };
189
+
190
+ const { container: multiContainer } = render(
191
+ <AwardMarquee
192
+ items={[itemWithMobile, { ...buildItems(1)[0] }]}
193
+ scroll={false}
194
+ />
195
+ );
196
+ expect(multiContainer.querySelectorAll('[alt="Mobile Logo"]')).toHaveLength(
197
+ 0
198
+ );
199
+ expect(screen.getByAltText("Desktop Logo")).not.toHaveClass("sm:hidden");
200
+
201
+ const { container: carouselContainer } = render(
202
+ <AwardMarquee items={[itemWithMobile]} scroll={true} />
203
+ );
204
+ // Split view for a single item never applies the mobile/desktop swap:
205
+ // the mobile half is already handled by the separate animated carousel,
206
+ // and the desktop half shows a single, unswapped image.
207
+ expect(
208
+ carouselContainer.querySelectorAll('[alt="Mobile Logo"]')
209
+ ).toHaveLength(0);
210
+ const desktopHalfIcon = carouselContainer.querySelector(
211
+ ".hidden.sm\\:block img"
212
+ );
213
+ expect(desktopHalfIcon).toHaveClass("w-full", "max-w-[1100px]", "h-auto");
214
+ expect(desktopHalfIcon).not.toHaveClass("sm:hidden");
215
+ });
216
+
106
217
  it("omits intrinsic icon dimensions when unavailable", () => {
107
218
  render(<AwardMarquee items={buildItems(1)} />);
108
219
  const icon = screen.getByAltText("Award 0");
@@ -139,6 +250,93 @@ describe("AwardMarquee", () => {
139
250
  expect(screen.getAllByText("Award 0")).toHaveLength(3);
140
251
  });
141
252
 
253
+ it("centers a single static entry and hides the caption block when it has no caption text", () => {
254
+ const { container } = render(
255
+ <AwardMarquee
256
+ items={[{ iconUrl: "https://example.com/logo.png", iconAlt: "Logo" }]}
257
+ scroll={false}
258
+ />
259
+ );
260
+ const wrapper = container.querySelector("#Award-Marquee > div > div");
261
+ expect(wrapper).toHaveClass("mx-auto", "w-full", "justify-center");
262
+ expect(wrapper).not.toHaveClass("ml-4", "w-auto");
263
+
264
+ const icon = screen.getByAltText("Logo");
265
+ expect(icon).toHaveClass("w-full", "max-w-[1100px]", "h-auto");
266
+ expect(icon).not.toHaveClass("w-[200px]");
267
+
268
+ // No title/description -> the 250px caption block takes no space.
269
+ expect(container.querySelector(".w-\\[250px\\]")).toBeNull();
270
+ });
271
+
272
+ it("still renders the caption block for a single static entry that has caption text", () => {
273
+ const { container } = render(
274
+ <AwardMarquee items={buildItems(1)} scroll={false} />
275
+ );
276
+ expect(container.querySelector(".w-\\[250px\\]")).not.toBeNull();
277
+ expect(screen.getByText("Award 0")).toBeInTheDocument();
278
+ expect(screen.getByText("Description 0")).toBeInTheDocument();
279
+ });
280
+
281
+ it("omits the caption block for any entry lacking caption text, even with multiple entries", () => {
282
+ const items: AwardMarqueeItem[] = [
283
+ ...buildItems(1),
284
+ { iconUrl: "https://example.com/logo-2.png", iconAlt: "Logo 2" },
285
+ ];
286
+ const { container } = render(<AwardMarquee items={items} scroll={false} />);
287
+ // Only the first item (with title/description) gets a caption block.
288
+ expect(container.querySelectorAll(".w-\\[250px\\]")).toHaveLength(1);
289
+ });
290
+
291
+ it("leaves multi-entry static rendering unchanged (fixed-width icon, ml-4 wrapper)", () => {
292
+ const { container } = render(
293
+ <AwardMarquee items={buildItems(2)} scroll={false} />
294
+ );
295
+ const wrappers = container.querySelectorAll("#Award-Marquee > div > div");
296
+ wrappers.forEach(wrapper => {
297
+ expect(wrapper).toHaveClass("ml-4", "w-auto");
298
+ expect(wrapper).not.toHaveClass("mx-auto", "justify-center");
299
+ });
300
+ const icon = screen.getByAltText("Award 0");
301
+ expect(icon).toHaveClass("w-[200px]");
302
+ expect(icon).not.toHaveClass("max-w-[1100px]");
303
+ });
304
+
305
+ it("leaves the multi-entry animated carousel unchanged regardless of single-item styling", () => {
306
+ const { container } = render(
307
+ <AwardMarquee items={buildItems(5)} scroll={true} />
308
+ );
309
+ const icons = screen.getAllByAltText(/Award \d/);
310
+ icons.forEach(icon => expect(icon).toHaveClass("w-[200px]"));
311
+ const wrappers = container.querySelectorAll(".animate-award-marquee > div");
312
+ wrappers.forEach(wrapper => {
313
+ expect(wrapper).toHaveClass("ml-4", "w-auto");
314
+ });
315
+ });
316
+
317
+ it("applies the single-static treatment only to the desktop (static) half of a split view", () => {
318
+ const { container } = render(
319
+ <AwardMarquee
320
+ items={[{ iconUrl: "https://example.com/logo.png", iconAlt: "Logo" }]}
321
+ scroll={true}
322
+ />
323
+ );
324
+ const mobileIcon = container.querySelector(
325
+ ".sm\\:hidden img"
326
+ ) as HTMLImageElement;
327
+ const desktopIcon = container.querySelector(
328
+ ".hidden.sm\\:block img"
329
+ ) as HTMLImageElement;
330
+
331
+ // Mobile (animated) half keeps the original fixed-width treatment.
332
+ expect(mobileIcon).toHaveClass("w-[200px]");
333
+ expect(mobileIcon).not.toHaveClass("max-w-[1100px]");
334
+
335
+ // Desktop (static) half gets the large, centered single-entry treatment.
336
+ expect(desktopIcon).toHaveClass("w-full", "max-w-[1100px]", "h-auto");
337
+ expect(desktopIcon).not.toHaveClass("w-[200px]");
338
+ });
339
+
142
340
  it("applies the anchorId to the wrapper in every render branch", () => {
143
341
  const staticView = render(
144
342
  <AwardMarquee
@@ -26,32 +26,76 @@ const renderAwardItem = (
26
26
  iconAlt,
27
27
  iconWidth,
28
28
  iconHeight,
29
+ mobileIconUrl,
30
+ mobileIconAlt,
31
+ mobileIconWidth,
32
+ mobileIconHeight,
29
33
  title,
30
34
  description,
31
35
  }: AwardMarqueeItem,
32
- key: React.Key
33
- ) => (
34
- <div
35
- key={key}
36
- className="ml-4 flex w-auto flex-shrink-0 items-center whitespace-nowrap"
37
- >
38
- <img
39
- src={iconUrl}
40
- alt={iconAlt ?? ""}
41
- width={iconWidth}
42
- height={iconHeight}
43
- className="w-[200px]"
44
- />
45
- <div className="w-[250px] text-center">
46
- <p className="flex justify-center text-center text-body3 font-bold">
47
- {title}
48
- </p>
49
- <p className="flex flex-wrap whitespace-normal text-center text-footnote">
50
- {description}
51
- </p>
36
+ key: React.Key,
37
+ // Only the single-entry, non-animated (static) rendering gets the large,
38
+ // centered treatment; the animated carousel is unaffected.
39
+ isSingleStatic = false,
40
+ // A distinct mobile image is only honored for the standalone static render
41
+ // (scroll={false}). When this is the "desktop half" of a responsive split
42
+ // (scroll={true} with <=3 items), the sm:hidden mobile side is already
43
+ // handled by the separate animated half, so a nested swap here would never
44
+ // be visible at any viewport.
45
+ allowMobileSwap = false
46
+ ) => {
47
+ const hasCaption = Boolean(title || description);
48
+ const hasMobileIcon = allowMobileSwap && Boolean(mobileIconUrl);
49
+
50
+ return (
51
+ <div
52
+ key={key}
53
+ className={cx(
54
+ "flex flex-shrink-0 items-center whitespace-nowrap",
55
+ isSingleStatic ? "mx-auto w-full justify-center" : "ml-4 w-auto"
56
+ )}
57
+ >
58
+ {hasMobileIcon ? (
59
+ <>
60
+ <img
61
+ src={mobileIconUrl}
62
+ alt={mobileIconAlt ?? iconAlt ?? ""}
63
+ width={mobileIconWidth}
64
+ height={mobileIconHeight}
65
+ className="h-auto w-full max-w-[1100px] sm:hidden"
66
+ />
67
+ <img
68
+ src={iconUrl}
69
+ alt={iconAlt ?? ""}
70
+ width={iconWidth}
71
+ height={iconHeight}
72
+ className="hidden h-auto w-full max-w-[1100px] sm:block"
73
+ />
74
+ </>
75
+ ) : (
76
+ <img
77
+ src={iconUrl}
78
+ alt={iconAlt ?? ""}
79
+ width={iconWidth}
80
+ height={iconHeight}
81
+ className={
82
+ isSingleStatic ? "h-auto w-full max-w-[1100px]" : "w-[200px]"
83
+ }
84
+ />
85
+ )}
86
+ {hasCaption && (
87
+ <div className="w-[250px] text-center">
88
+ <p className="flex justify-center text-center text-body3 font-bold">
89
+ {title}
90
+ </p>
91
+ <p className="flex flex-wrap whitespace-normal text-center text-footnote">
92
+ {description}
93
+ </p>
94
+ </div>
95
+ )}
52
96
  </div>
53
- </div>
54
- );
97
+ );
98
+ };
55
99
 
56
100
  export const AwardMarquee: React.FC<AwardMarqueeProps> = ({
57
101
  anchorId = "Award-Marquee",
@@ -91,20 +135,27 @@ export const AwardMarquee: React.FC<AwardMarqueeProps> = ({
91
135
  );
92
136
  };
93
137
 
94
- const staticMarquee = (
138
+ const staticMarquee = (allowMobileSwap: boolean) => (
95
139
  <div className="flex flex-nowrap items-center justify-around overflow-clip">
96
- {items.map((item, index) => renderAwardItem(item, index))}
140
+ {items.map((item, index) =>
141
+ renderAwardItem(
142
+ item,
143
+ index,
144
+ items.length === 1,
145
+ allowMobileSwap && items.length === 1
146
+ )
147
+ )}
97
148
  </div>
98
149
  );
99
150
 
100
151
  const marquee = !scroll ? (
101
- staticMarquee
152
+ staticMarquee(true)
102
153
  ) : scrollAvailable ? (
103
154
  animatedMarquee()
104
155
  ) : (
105
156
  <>
106
157
  <div className="sm:hidden">{animatedMarquee()}</div>
107
- <div className="hidden sm:block">{staticMarquee}</div>
158
+ <div className="hidden sm:block">{staticMarquee(false)}</div>
108
159
  </>
109
160
  );
110
161
 
@@ -7,6 +7,12 @@ export type AwardMarqueeItem = {
7
7
  iconAlt?: string;
8
8
  iconWidth?: number;
9
9
  iconHeight?: number;
10
+ // Optional mobile-specific image for the single-entry static display.
11
+ // Falls back to iconUrl at every breakpoint when omitted.
12
+ mobileIconUrl?: string;
13
+ mobileIconAlt?: string;
14
+ mobileIconWidth?: number;
15
+ mobileIconHeight?: number;
10
16
  title?: string;
11
17
  description?: string;
12
18
  };