@windstream/react-shared-components 0.2.88 → 0.2.90

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 (29) hide show
  1. package/dist/contentful/index.d.ts +14 -0
  2. package/dist/contentful/index.esm.js +4 -4
  3. package/dist/contentful/index.esm.js.map +1 -1
  4. package/dist/contentful/index.js +4 -4
  5. package/dist/contentful/index.js.map +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/utils/index.esm.js.map +1 -1
  8. package/dist/utils/index.js.map +1 -1
  9. package/package.json +1 -1
  10. package/src/contentful/blocks/anchored-bottom-banner/index.test.tsx +55 -0
  11. package/src/contentful/blocks/anchored-bottom-banner/index.tsx +15 -2
  12. package/src/contentful/blocks/anchored-bottom-banner/types.ts +5 -0
  13. package/src/contentful/blocks/award-marquee/index.test.tsx +75 -0
  14. package/src/contentful/blocks/award-marquee/index.tsx +40 -19
  15. package/src/contentful/blocks/award-marquee/types.ts +6 -0
  16. package/src/contentful/blocks/cart-retention-banner/index.test.tsx +63 -3
  17. package/src/contentful/blocks/cart-retention-banner/index.tsx +21 -8
  18. package/src/contentful/blocks/cart-retention-banner/types.ts +4 -0
  19. package/src/contentful/blocks/floating-banner/index.test.tsx +54 -2
  20. package/src/contentful/blocks/floating-banner/index.tsx +13 -2
  21. package/src/contentful/blocks/floating-banner/types.ts +6 -0
  22. package/src/contentful/blocks/image-promo-bar/index.test.tsx +176 -18
  23. package/src/contentful/blocks/image-promo-bar/index.tsx +45 -27
  24. package/src/contentful/blocks/image-promo-bar/types.ts +19 -1
  25. package/src/contentful/blocks/primary-hero/index.test.tsx +263 -4
  26. package/src/contentful/blocks/primary-hero/index.tsx +48 -4
  27. package/src/contentful/blocks/primary-hero/primary-hero.stories.tsx +22 -1
  28. package/src/utils/replace-tokens.test.ts +34 -0
  29. package/src/utils/replace-tokens.ts +4 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windstream/react-shared-components",
3
- "version": "0.2.88",
3
+ "version": "0.2.90",
4
4
  "type": "module",
5
5
  "description": "Shared React components for Kinetic applications",
6
6
  "main": "dist/index.js",
@@ -44,6 +44,61 @@ describe("AnchoredBottomBanner", () => {
44
44
  expect(screen.getByText("Limited time")).toBeInTheDocument();
45
45
  });
46
46
 
47
+ it("tags CTA label and suffix fields", () => {
48
+ render(
49
+ <AnchoredBottomBanner
50
+ ctaButtonLabel="Shop now"
51
+ ctaSuffixText="Limited time"
52
+ fieldProps={{
53
+ ctaButtonLabel: { "data-contentful-field-id": "ctaButtonLabel" },
54
+ ctaSuffixText: { "data-contentful-field-id": "ctaSuffixText" },
55
+ }}
56
+ />
57
+ );
58
+ expect(screen.getByText("Shop now")).toHaveAttribute(
59
+ "data-contentful-field-id",
60
+ "ctaButtonLabel"
61
+ );
62
+ expect(screen.getByText("Limited time")).toHaveAttribute(
63
+ "data-contentful-field-id",
64
+ "ctaSuffixText"
65
+ );
66
+ });
67
+
68
+ it("uses active scheduled-message fieldProps for its CTA content", () => {
69
+ render(
70
+ <AnchoredBottomBanner
71
+ ctaButtonLabel="Default label"
72
+ fieldProps={{
73
+ ctaButtonLabel: { "data-contentful-field-id": "defaultLabel" },
74
+ }}
75
+ scheduledMessages={[
76
+ {
77
+ ctaButtonLabel: "Scheduled label",
78
+ ctaSuffixText: "Scheduled suffix",
79
+ fieldProps: {
80
+ ctaButtonLabel: {
81
+ "data-contentful-field-id": "scheduledLabel",
82
+ },
83
+ ctaSuffixText: {
84
+ "data-contentful-field-id": "scheduledSuffix",
85
+ },
86
+ },
87
+ },
88
+ ]}
89
+ />
90
+ );
91
+ expect(screen.getByText("Scheduled label")).toHaveAttribute(
92
+ "data-contentful-field-id",
93
+ "scheduledLabel"
94
+ );
95
+ expect(screen.getByText("Scheduled suffix")).toHaveAttribute(
96
+ "data-contentful-field-id",
97
+ "scheduledSuffix"
98
+ );
99
+ expect(screen.queryByText("Default label")).not.toBeInTheDocument();
100
+ });
101
+
47
102
  it("renders iconName", () => {
48
103
  render(<AnchoredBottomBanner iconName="local_offer" />);
49
104
  expect(screen.getByText("local_offer")).toBeInTheDocument();
@@ -149,6 +149,7 @@ export const AnchoredBottomBanner: React.FC<AnchoredBottomBannerProps> = ({
149
149
  scheduledMessages,
150
150
  initialNowMs,
151
151
  onResolved,
152
+ fieldProps,
152
153
  }) => {
153
154
  const backGroundColorClasses: Record<string, string> = {
154
155
  navy: "bg-bg-fill-inverse",
@@ -314,6 +315,9 @@ export const AnchoredBottomBanner: React.FC<AnchoredBottomBannerProps> = ({
314
315
  const effectiveCtaButtonTarget = hasActiveMessage
315
316
  ? activeMessage?.ctaButtonTarget
316
317
  : ctaButtonTarget;
318
+ const effectiveFieldProps = hasActiveMessage
319
+ ? activeMessage?.fieldProps
320
+ : fieldProps;
317
321
 
318
322
  // Active message owns its background; when unset it uses the same default as
319
323
  // the parent-with-no-background (bg-fill-brand-accent), not the parent's color.
@@ -370,9 +374,18 @@ export const AnchoredBottomBanner: React.FC<AnchoredBottomBannerProps> = ({
370
374
  </span>
371
375
  )}
372
376
  {countdown.shouldShow ? " " : null}
373
- {effectiveCtaButtonLabel && effectiveCtaButtonLabel}{" "}
377
+ {effectiveCtaButtonLabel && (
378
+ <span {...effectiveFieldProps?.ctaButtonLabel}>
379
+ {effectiveCtaButtonLabel}
380
+ </span>
381
+ )}{" "}
374
382
  {effectiveCtaSuffixText && (
375
- <span className="ml-0.5">{effectiveCtaSuffixText}</span>
383
+ <span
384
+ className="ml-0.5"
385
+ {...effectiveFieldProps?.ctaSuffixText}
386
+ >
387
+ {effectiveCtaSuffixText}
388
+ </span>
376
389
  )}
377
390
  </div>
378
391
  </div>
@@ -1,3 +1,5 @@
1
+ import type { FieldProps } from "@shared/types/field-props";
2
+
1
3
  // Allowed values for a scheduled message's daysToShow.
2
4
  // "ALL" (or empty) = every day; full names or 3-letter abbreviations, uppercase.
3
5
  export type ScheduledMessageDay =
@@ -49,6 +51,7 @@ export interface ScheduledMessage {
49
51
  ctaSuffixText?: string;
50
52
  ctaButtonLink?: string;
51
53
  ctaButtonTarget?: string;
54
+ fieldProps?: FieldProps<"ctaButtonLabel" | "ctaSuffixText">;
52
55
  }
53
56
 
54
57
  export interface AnchoredBottomBannerProps {
@@ -77,4 +80,6 @@ export interface AnchoredBottomBannerProps {
77
80
  activeMessage?: ScheduledMessage;
78
81
  applicableWeekday: string;
79
82
  }) => void;
83
+ /** DOM attributes for the rendered CTA label and suffix fields. */
84
+ fieldProps?: FieldProps<"ctaButtonLabel" | "ctaSuffixText">;
80
85
  }
@@ -15,6 +15,81 @@ const buildItems = (count: number): AwardMarqueeItem[] =>
15
15
  }));
16
16
 
17
17
  describe("AwardMarquee", () => {
18
+ it("tags block heading/footer and per-award title/description", () => {
19
+ render(
20
+ <AwardMarquee
21
+ heading="Heading"
22
+ footerText="Footer"
23
+ items={[
24
+ {
25
+ iconUrl: "/award.png",
26
+ title: "Award title",
27
+ description: "Award description",
28
+ fieldProps: {
29
+ title: {
30
+ "data-contentful-entry-id": "award-1",
31
+ "data-contentful-field-id": "title",
32
+ "data-contentful-locale": "en-US",
33
+ },
34
+ description: {
35
+ "data-contentful-entry-id": "award-1",
36
+ "data-contentful-field-id": "description",
37
+ "data-contentful-locale": "en-US",
38
+ },
39
+ },
40
+ },
41
+ ]}
42
+ fieldProps={{
43
+ heading: { "data-contentful-field-id": "heading" },
44
+ footerText: { "data-contentful-field-id": "footerText" },
45
+ }}
46
+ />
47
+ );
48
+ expect(screen.getByText("Heading")).toHaveAttribute(
49
+ "data-contentful-field-id",
50
+ "heading"
51
+ );
52
+ expect(screen.getByText("Footer")).toHaveAttribute(
53
+ "data-contentful-field-id",
54
+ "footerText"
55
+ );
56
+ expect(screen.getByText("Award title")).toHaveAttribute(
57
+ "data-contentful-field-id",
58
+ "title"
59
+ );
60
+ expect(screen.getByText("Award description")).toHaveAttribute(
61
+ "data-contentful-field-id",
62
+ "description"
63
+ );
64
+ });
65
+
66
+ it("does not duplicate item field tags in the animated marquee loop", () => {
67
+ const { container } = render(
68
+ <AwardMarquee
69
+ scroll={true}
70
+ items={buildItems(5).map((item, index) => ({
71
+ ...item,
72
+ fieldProps: {
73
+ title: {
74
+ "data-contentful-entry-id": `award-${index}`,
75
+ "data-contentful-field-id": "title",
76
+ },
77
+ description: {
78
+ "data-contentful-entry-id": `award-${index}`,
79
+ "data-contentful-field-id": "description",
80
+ },
81
+ },
82
+ }))}
83
+ />
84
+ );
85
+ expect(
86
+ container.querySelectorAll('[data-contentful-field-id="title"]')
87
+ ).toHaveLength(5);
88
+ expect(
89
+ container.querySelectorAll('[data-contentful-field-id="description"]')
90
+ ).toHaveLength(5);
91
+ });
92
+
18
93
  it.each([
19
94
  { scroll: false, count: 2 },
20
95
  { scroll: true, count: 5 },
@@ -21,18 +21,7 @@ const BACKGROUND_CLASS: Record<AwardMarqueeBackground, string> = {
21
21
  };
22
22
 
23
23
  const renderAwardItem = (
24
- {
25
- iconUrl,
26
- iconAlt,
27
- iconWidth,
28
- iconHeight,
29
- mobileIconUrl,
30
- mobileIconAlt,
31
- mobileIconWidth,
32
- mobileIconHeight,
33
- title,
34
- description,
35
- }: AwardMarqueeItem,
24
+ item: AwardMarqueeItem,
36
25
  key: React.Key,
37
26
  // Only the single-entry, non-animated (static) rendering gets the large,
38
27
  // centered treatment; the animated carousel is unaffected.
@@ -42,8 +31,21 @@ const renderAwardItem = (
42
31
  // (scroll={true} with <=3 items), the sm:hidden mobile side is already
43
32
  // handled by the separate animated half, so a nested swap here would never
44
33
  // be visible at any viewport.
45
- allowMobileSwap = false
34
+ allowMobileSwap = false,
35
+ includeFieldProps = true
46
36
  ) => {
37
+ const {
38
+ iconUrl,
39
+ iconAlt,
40
+ iconWidth,
41
+ iconHeight,
42
+ mobileIconUrl,
43
+ mobileIconAlt,
44
+ mobileIconWidth,
45
+ mobileIconHeight,
46
+ title,
47
+ description,
48
+ } = item;
47
49
  const hasCaption = Boolean(title || description);
48
50
  const hasMobileIcon = allowMobileSwap && Boolean(mobileIconUrl);
49
51
 
@@ -85,10 +87,16 @@ const renderAwardItem = (
85
87
  )}
86
88
  {hasCaption && (
87
89
  <div className="w-[250px] text-center">
88
- <p className="flex justify-center text-center text-body3 font-bold">
90
+ <p
91
+ className="flex justify-center text-center text-body3 font-bold"
92
+ {...(includeFieldProps ? item.fieldProps?.title : undefined)}
93
+ >
89
94
  {title}
90
95
  </p>
91
- <p className="flex flex-wrap whitespace-normal text-center text-footnote">
96
+ <p
97
+ className="flex flex-wrap whitespace-normal text-center text-footnote"
98
+ {...(includeFieldProps ? item.fieldProps?.description : undefined)}
99
+ >
92
100
  {description}
93
101
  </p>
94
102
  </div>
@@ -106,6 +114,7 @@ export const AwardMarquee: React.FC<AwardMarqueeProps> = ({
106
114
  scroll = false,
107
115
  backgroundColor = "white",
108
116
  className,
117
+ fieldProps,
109
118
  }) => {
110
119
  const scrollAvailable = items.length > 3;
111
120
  const shouldDuplicate = items.length < 8;
@@ -129,7 +138,9 @@ export const AwardMarquee: React.FC<AwardMarqueeProps> = ({
129
138
  } as React.CSSProperties
130
139
  }
131
140
  >
132
- {marqueeItems.map((item, index) => renderAwardItem(item, index))}
141
+ {marqueeItems.map((item, index) =>
142
+ renderAwardItem(item, index, false, false, index < items.length)
143
+ )}
133
144
  </div>
134
145
  </div>
135
146
  );
@@ -142,7 +153,8 @@ export const AwardMarquee: React.FC<AwardMarqueeProps> = ({
142
153
  item,
143
154
  index,
144
155
  items.length === 1,
145
- allowMobileSwap && items.length === 1
156
+ allowMobileSwap && items.length === 1,
157
+ true
146
158
  )
147
159
  )}
148
160
  </div>
@@ -161,10 +173,19 @@ export const AwardMarquee: React.FC<AwardMarqueeProps> = ({
161
173
 
162
174
  return (
163
175
  <div id={anchorId} className={className}>
164
- {heading && <p className="heading2 mb-4 px-4 text-center">{heading}</p>}
176
+ {heading && (
177
+ <p className="heading2 mb-4 px-4 text-center" {...fieldProps?.heading}>
178
+ {heading}
179
+ </p>
180
+ )}
165
181
  {marquee}
166
182
  {footerText && (
167
- <p className="subheading3 mt-4 px-4 text-center">{footerText}</p>
183
+ <p
184
+ className="subheading3 mt-4 px-4 text-center"
185
+ {...fieldProps?.footerText}
186
+ >
187
+ {footerText}
188
+ </p>
168
189
  )}
169
190
  </div>
170
191
  );
@@ -1,3 +1,5 @@
1
+ import type { FieldProps } from "@shared/types/field-props";
2
+
1
3
  export type AwardMarqueeSpeed = "fast" | "medium" | "slow";
2
4
 
3
5
  export type AwardMarqueeBackground = "cream" | "gray90" | "white";
@@ -15,6 +17,8 @@ export type AwardMarqueeItem = {
15
17
  mobileIconHeight?: number;
16
18
  title?: string;
17
19
  description?: string;
20
+ /** Inspector attributes for this repeated award item's text. */
21
+ fieldProps?: FieldProps<"title" | "description">;
18
22
  };
19
23
 
20
24
  export type AwardMarqueeProps = {
@@ -26,4 +30,6 @@ export type AwardMarqueeProps = {
26
30
  scroll?: boolean;
27
31
  backgroundColor?: AwardMarqueeBackground;
28
32
  className?: string;
33
+ /** Inspector attributes for the block heading and footer text. */
34
+ fieldProps?: FieldProps<"heading" | "footerText">;
29
35
  };
@@ -11,8 +11,13 @@ jest.mock("@shared/components/text", () => ({
11
11
  }));
12
12
 
13
13
  jest.mock("@shared/contentful/blocks/button", () => ({
14
- Button: ({ children, onClick, buttonLabel, ...props }: any) => (
15
- <button onClick={onClick} data-testid="banner-button" {...props}>
14
+ Button: ({ children, onClick, buttonLabel, fieldProps, ...props }: any) => (
15
+ <button
16
+ onClick={onClick}
17
+ data-testid="banner-button"
18
+ {...props}
19
+ {...fieldProps}
20
+ >
16
21
  {buttonLabel || children}
17
22
  </button>
18
23
  ),
@@ -70,6 +75,59 @@ describe("CartRetentionBanner", () => {
70
75
  });
71
76
 
72
77
  describe("modal behavior", () => {
78
+ it("tags the heading, rich-text description and CTA", () => {
79
+ render(
80
+ <CartRetentionBanner
81
+ fields={defaultFields}
82
+ fieldProps={{
83
+ mainHeading: {
84
+ "data-contentful-field-id": "mainHeading",
85
+ "data-contentful-entry-id": "cart-banner",
86
+ "data-contentful-locale": "en-US",
87
+ },
88
+ description: {
89
+ "data-contentful-field-id": "description",
90
+ "data-contentful-entry-id": "cart-banner",
91
+ "data-contentful-locale": "en-US",
92
+ },
93
+ cta: {
94
+ "data-contentful-field-id": "cta",
95
+ "data-contentful-entry-id": "cart-banner",
96
+ "data-contentful-locale": "en-US",
97
+ },
98
+ }}
99
+ checkShouldShowBanner={() => true}
100
+ />
101
+ );
102
+ act(() => {
103
+ jest.advanceTimersByTime(200);
104
+ });
105
+
106
+ expect(screen.getByText("Welcome back!")).toHaveAttribute(
107
+ "data-contentful-field-id",
108
+ "mainHeading"
109
+ );
110
+ expect(screen.getByTestId("rich-text").parentElement).toHaveAttribute(
111
+ "data-contentful-field-id",
112
+ "description"
113
+ );
114
+ expect(screen.getByTestId("banner-button")).toHaveAttribute(
115
+ "data-contentful-field-id",
116
+ "cta"
117
+ );
118
+ [
119
+ screen.getByText("Welcome back!"),
120
+ screen.getByTestId("rich-text").parentElement,
121
+ screen.getByTestId("banner-button"),
122
+ ].forEach(element => {
123
+ expect(element).toHaveAttribute(
124
+ "data-contentful-entry-id",
125
+ "cart-banner"
126
+ );
127
+ expect(element).toHaveAttribute("data-contentful-locale", "en-US");
128
+ });
129
+ });
130
+
73
131
  it("renders a section with anchorId", () => {
74
132
  render(<CartRetentionBanner fields={defaultFields} />);
75
133
  const section = document.querySelector("section#cart-banner");
@@ -416,7 +474,9 @@ describe("CartRetentionBanner", () => {
416
474
 
417
475
  describe("image and layout options", () => {
418
476
  it("does not render an image when none is provided", () => {
419
- render(<CartRetentionBanner fields={defaultFields} isInPopupContainer={true} />);
477
+ render(
478
+ <CartRetentionBanner fields={defaultFields} isInPopupContainer={true} />
479
+ );
420
480
  expect(screen.queryByTestId("banner-image")).not.toBeInTheDocument();
421
481
  });
422
482
 
@@ -1,7 +1,10 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useState } from "react";
4
- import type { TypeCartRetentionBannerFields } from "./types";
4
+ import type {
5
+ CartRetentionBannerFieldProps,
6
+ TypeCartRetentionBannerFields,
7
+ } from "./types";
5
8
 
6
9
  import { NextImage } from "@shared/components/next-image";
7
10
  import { Text } from "@shared/components/text";
@@ -45,6 +48,7 @@ const isShapeBackgroundThemeKey = (
45
48
 
46
49
  const CartRetentionBanner: React.FC<{
47
50
  fields: TypeCartRetentionBannerFields;
51
+ fieldProps?: CartRetentionBannerFieldProps;
48
52
  sys?: any;
49
53
  isInPopupContainer?: boolean;
50
54
  onClose?: () => void;
@@ -53,6 +57,7 @@ const CartRetentionBanner: React.FC<{
53
57
  onBannerShown?: () => void;
54
58
  }> = ({
55
59
  fields,
60
+ fieldProps,
56
61
  isInPopupContainer = false,
57
62
  onClose,
58
63
  onContinueCheckout,
@@ -174,15 +179,22 @@ const CartRetentionBanner: React.FC<{
174
179
  "heading2",
175
180
  hasSideImage ? "text-center md:text-left" : "text-center"
176
181
  )}
182
+ {...fieldProps?.mainHeading}
177
183
  >
178
184
  {mainHeading || "Welcome back. Let's finish your order"}
179
185
  </Text>
180
- {description &&
181
- renderContentfulRichText(
182
- toDocument(description?.json),
183
- false,
184
- cx("body2", hasSideImage ? "text-center md:text-left" : "text-center")
185
- )}
186
+ {description && (
187
+ <div {...fieldProps?.description}>
188
+ {renderContentfulRichText(
189
+ toDocument(description?.json),
190
+ false,
191
+ cx(
192
+ "body2",
193
+ hasSideImage ? "text-center md:text-left" : "text-center"
194
+ )
195
+ )}
196
+ </div>
197
+ )}
186
198
  <div
187
199
  className={cx(
188
200
  "flex w-full pt-5 md:px-4",
@@ -195,6 +207,7 @@ const CartRetentionBanner: React.FC<{
195
207
  disableAnimation={cta?.disableAnimation ?? true}
196
208
  animationType={cta?.animationType}
197
209
  fullWidth={true}
210
+ fieldProps={cta?.fieldProps ?? fieldProps?.cta}
198
211
  />
199
212
  </div>
200
213
  </>
@@ -288,7 +301,7 @@ const CartRetentionBanner: React.FC<{
288
301
  ) : (
289
302
  // overflow-x:clip contains the CTA hover-scale (keeps overflow-y);
290
303
  // px keeps the full-width button off the modal border.
291
- <div className="pop-up-body mb-5 w-full [overflow-x:clip] px-3 md:px-6">
304
+ <div className="pop-up-body mb-5 w-full px-3 [overflow-x:clip] md:px-6">
292
305
  {wrappedBodyContent}
293
306
  </div>
294
307
  )}
@@ -1,4 +1,5 @@
1
1
  import type { AnimationType } from "@shared/components/animation-wrapper";
2
+ import type { FieldProps } from "@shared/types/field-props";
2
3
 
3
4
  export type CartRetentionBannerImagePosition = "left" | "right" | "center";
4
5
 
@@ -50,3 +51,6 @@ export interface TypeCartRetentionBannerFields {
50
51
  }
51
52
 
52
53
  export type TypeCartRetentionBanner = TypeCartRetentionBannerFields;
54
+ export type CartRetentionBannerFieldProps = FieldProps<
55
+ "mainHeading" | "description" | "cta"
56
+ >;
@@ -20,8 +20,10 @@ jest.mock("@shared/components/next-image", () => ({
20
20
  }));
21
21
 
22
22
  jest.mock("@shared/components/text", () => ({
23
- Text: ({ as: Tag = "span", children, className }: any) => (
24
- <Tag className={className}>{children}</Tag>
23
+ Text: ({ as: Tag = "span", children, className, ...rest }: any) => (
24
+ <Tag className={className} {...rest}>
25
+ {children}
26
+ </Tag>
25
27
  ),
26
28
  }));
27
29
 
@@ -42,6 +44,56 @@ const defaultProps: FloatingBannerProps = {
42
44
  };
43
45
 
44
46
  describe("FloatingBanner", () => {
47
+ it("spreads fieldProps onto each rendered content field", () => {
48
+ const tag = (field: string) => ({
49
+ "data-contentful-field-id": field,
50
+ "data-contentful-entry-id": "floating-banner",
51
+ "data-contentful-locale": "en-US",
52
+ });
53
+ const { container } = render(
54
+ <FloatingBanner
55
+ {...defaultProps}
56
+ fieldProps={{
57
+ title: tag("title"),
58
+ subtitle: tag("subtitle"),
59
+ description: tag("description"),
60
+ disclaimer: tag("disclaimer"),
61
+ cta: tag("cta"),
62
+ }}
63
+ />
64
+ );
65
+ ["title", "subtitle", "description", "disclaimer", "cta"].forEach(field => {
66
+ expect(
67
+ container.querySelector(`[data-contentful-field-id="${field}"]`)
68
+ ).toHaveAttribute("data-contentful-entry-id", "floating-banner");
69
+ });
70
+ container
71
+ .querySelectorAll("[data-contentful-field-id]")
72
+ .forEach(element =>
73
+ expect(element).toHaveAttribute("data-contentful-locale", "en-US")
74
+ );
75
+ });
76
+
77
+ it("supports partial fieldProps and omits tags when a field is not rendered", () => {
78
+ const { container } = render(
79
+ <FloatingBanner
80
+ title="Only title"
81
+ fieldProps={{
82
+ title: { "data-contentful-field-id": "title" },
83
+ description: { "data-contentful-field-id": "description" },
84
+ }}
85
+ />
86
+ );
87
+ expect(
88
+ container.querySelectorAll("[data-contentful-field-id]")
89
+ ).toHaveLength(1);
90
+ });
91
+
92
+ it("does not add tags when fieldProps is omitted", () => {
93
+ const { container } = render(<FloatingBanner {...defaultProps} />);
94
+ expect(container.querySelector("[data-contentful-field-id]")).toBeNull();
95
+ });
96
+
45
97
  describe("Rendering", () => {
46
98
  it("renders with all props", () => {
47
99
  render(<FloatingBanner {...defaultProps} />);
@@ -16,6 +16,7 @@ export const FloatingBanner: React.FC<FloatingBannerProps> = ({
16
16
  color = "light",
17
17
  enableHeading,
18
18
  maxWidth = true,
19
+ fieldProps,
19
20
  }) => {
20
21
  const bgColorClasses: Record<ThemeKey, string> = {
21
22
  blue: "bg-[#07B2E2]",
@@ -51,6 +52,7 @@ export const FloatingBanner: React.FC<FloatingBannerProps> = ({
51
52
  <Text
52
53
  as={enableHeading ? "h1" : "h2"}
53
54
  className={`heading5 mb-3 text-center lg:w-[95%] lg:text-left`}
55
+ {...fieldProps?.title}
54
56
  >
55
57
  {title}
56
58
  </Text>
@@ -59,6 +61,7 @@ export const FloatingBanner: React.FC<FloatingBannerProps> = ({
59
61
  <Text
60
62
  as={"h3"}
61
63
  className={`heading3 mb-3 text-center lg:text-left`}
64
+ {...fieldProps?.subtitle}
62
65
  >
63
66
  {subtitle}
64
67
  </Text>
@@ -67,13 +70,17 @@ export const FloatingBanner: React.FC<FloatingBannerProps> = ({
67
70
  <Text
68
71
  as="div"
69
72
  className="body1 text-center lg:w-[95%] lg:text-left"
73
+ {...fieldProps?.description}
70
74
  >
71
75
  {description}
72
76
  </Text>
73
77
  )}
74
78
  </div>
75
79
  {cta && (
76
- <div className="mt-5 flex flex-shrink-0 justify-center lg:mt-0 lg:items-center">
80
+ <div
81
+ className="mt-5 flex flex-shrink-0 justify-center lg:mt-0 lg:items-center"
82
+ {...fieldProps?.cta}
83
+ >
77
84
  <Button
78
85
  buttonVariant="secondary"
79
86
  {...cta}
@@ -84,7 +91,11 @@ export const FloatingBanner: React.FC<FloatingBannerProps> = ({
84
91
  </div>
85
92
  {disclaimer && (
86
93
  <div>
87
- <Text as="div" className="body3 mt-3 text-center">
94
+ <Text
95
+ as="div"
96
+ className="body3 mt-3 text-center"
97
+ {...fieldProps?.disclaimer}
98
+ >
88
99
  {disclaimer}
89
100
  </Text>
90
101
  </div>
@@ -1,5 +1,7 @@
1
1
  import React from "react";
2
2
 
3
+ import type { FieldProps } from "@shared/types/field-props";
4
+
3
5
  export type FloatingBannerProps = {
4
6
  title?: string;
5
7
  enableHeading?: boolean;
@@ -11,6 +13,10 @@ export type FloatingBannerProps = {
11
13
  background?: "green" | "navy" | "white" | "purple" | "blue" | "yellow";
12
14
  color?: "dark" | "light";
13
15
  maxWidth?: boolean;
16
+ /** Consumer-supplied DOM attributes for the rendered content fields. */
17
+ fieldProps?: FieldProps<
18
+ "title" | "subtitle" | "description" | "disclaimer" | "cta"
19
+ >;
14
20
  };
15
21
 
16
22
  export type ThemeKey =