@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
@@ -7,22 +7,42 @@ import { render, screen } from "@testing-library/react";
7
7
  jest.mock("../button", () => ({
8
8
  __esModule: true,
9
9
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
10
- Button: ({ buttonLabel, disableAnimation, ...rest }: any) => (
11
- <button
12
- data-testid="cta-button"
13
- data-disable-animation={String(disableAnimation)}
14
- >
15
- {buttonLabel || "CTA"}
16
- </button>
17
- ),
18
- default: ({ buttonLabel, disableAnimation }: any) => (
19
- <button
20
- data-testid="cta-button"
21
- data-disable-animation={String(disableAnimation)}
22
- >
23
- {buttonLabel || "CTA"}
24
- </button>
25
- ),
10
+ Button: ({
11
+ buttonLabel,
12
+ disableAnimation,
13
+ fieldProps,
14
+ preDefinedFunctionExecution,
15
+ renderCheckPlans,
16
+ }: any) =>
17
+ preDefinedFunctionExecution === "check availability" && renderCheckPlans ? (
18
+ renderCheckPlans()
19
+ ) : (
20
+ <button
21
+ data-testid="cta-button"
22
+ data-disable-animation={String(disableAnimation)}
23
+ {...fieldProps}
24
+ >
25
+ {buttonLabel || "CTA"}
26
+ </button>
27
+ ),
28
+ default: ({
29
+ buttonLabel,
30
+ disableAnimation,
31
+ fieldProps,
32
+ preDefinedFunctionExecution,
33
+ renderCheckPlans,
34
+ }: any) =>
35
+ preDefinedFunctionExecution === "check availability" && renderCheckPlans ? (
36
+ renderCheckPlans()
37
+ ) : (
38
+ <button
39
+ data-testid="cta-button"
40
+ data-disable-animation={String(disableAnimation)}
41
+ {...fieldProps}
42
+ >
43
+ {buttonLabel || "CTA"}
44
+ </button>
45
+ ),
26
46
  }));
27
47
 
28
48
  jest.mock("@shared/components/video-link", () => ({
@@ -63,8 +83,10 @@ jest.mock("@shared/components/next-image", () => ({
63
83
  }));
64
84
 
65
85
  jest.mock("@shared/components/text", () => ({
66
- Text: ({ as: Tag = "span", children, className }: any) => (
67
- <Tag className={className}>{children}</Tag>
86
+ Text: ({ as: Tag = "span", children, className, ...rest }: any) => (
87
+ <Tag className={className} {...rest}>
88
+ {children}
89
+ </Tag>
68
90
  ),
69
91
  }));
70
92
 
@@ -94,6 +116,142 @@ const defaultProps: ImagePromoBarProps = {
94
116
  };
95
117
 
96
118
  describe("ImagePromoBar", () => {
119
+ it("tags block content, CTAs, disclaimers, checklist and image links", () => {
120
+ const tag = (field: string) => ({
121
+ "data-contentful-field-id": field,
122
+ "data-contentful-entry-id": "image-promo",
123
+ "data-contentful-locale": "en-US",
124
+ });
125
+ const { container } = render(
126
+ <ImagePromoBar
127
+ {...defaultProps}
128
+ brow="Brow"
129
+ title="Title"
130
+ subTitle="Subtitle"
131
+ description="Description"
132
+ checklist={["Check"]}
133
+ ctaDisclaimer="CTA disclaimer"
134
+ disclaimer="Disclaimer"
135
+ imageLinks={[
136
+ {
137
+ url: "/image",
138
+ image: "/image.png",
139
+ fieldProps: {
140
+ image: {
141
+ ...tag("image"),
142
+ "data-contentful-entry-id": "image-link-item",
143
+ },
144
+ },
145
+ },
146
+ ]}
147
+ cta={{
148
+ buttonLabel: "Primary",
149
+ fieldProps: { "data-contentful-field-id": "nestedPrimaryCta" },
150
+ }}
151
+ secondaryCta={{
152
+ buttonLabel: "Secondary",
153
+ fieldProps: { "data-contentful-field-id": "nestedSecondaryCta" },
154
+ }}
155
+ fieldProps={{
156
+ brow: tag("brow"),
157
+ title: tag("title"),
158
+ subTitle: tag("subTitle"),
159
+ description: tag("description"),
160
+ checklist: tag("checklist"),
161
+ ctaDisclaimer: tag("ctaDisclaimer"),
162
+ cta: tag("cta"),
163
+ secondaryCta: tag("secondaryCta"),
164
+ disclaimer: tag("disclaimer"),
165
+ imageLinks: tag("imageLinks"),
166
+ }}
167
+ />
168
+ );
169
+ [
170
+ "brow",
171
+ "title",
172
+ "subTitle",
173
+ "description",
174
+ "checklist",
175
+ "ctaDisclaimer",
176
+ "cta",
177
+ "secondaryCta",
178
+ "disclaimer",
179
+ "imageLinks",
180
+ ].forEach(field => {
181
+ expect(
182
+ container.querySelector(`[data-contentful-field-id="${field}"]`)
183
+ ).toHaveAttribute("data-contentful-entry-id", "image-promo");
184
+ });
185
+ expect(
186
+ container.querySelector(
187
+ '[data-contentful-entry-id="image-link-item"][data-contentful-field-id="image"]'
188
+ )
189
+ ).toBeInTheDocument();
190
+ expect(screen.getAllByTestId("cta-button")[0]).toHaveAttribute(
191
+ "data-contentful-field-id",
192
+ "nestedPrimaryCta"
193
+ );
194
+ expect(screen.getAllByTestId("cta-button")[1]).toHaveAttribute(
195
+ "data-contentful-field-id",
196
+ "nestedSecondaryCta"
197
+ );
198
+ });
199
+
200
+ it("applies block CTA tags to wrappers around custom check-plans content", () => {
201
+ const tag = (field: string) => ({
202
+ "data-contentful-field-id": field,
203
+ "data-contentful-entry-id": "image-promo",
204
+ });
205
+ const { container } = render(
206
+ <ImagePromoBar
207
+ {...defaultProps}
208
+ cta={{
209
+ preDefinedFunctionExecution: "check availability",
210
+ fieldProps: { "data-contentful-field-id": "primaryButton" },
211
+ }}
212
+ secondaryCta={{
213
+ preDefinedFunctionExecution: "check availability",
214
+ fieldProps: { "data-contentful-field-id": "secondaryButton" },
215
+ }}
216
+ renderCheckPlans={() => <button>Check availability</button>}
217
+ fieldProps={{
218
+ cta: tag("cta"),
219
+ secondaryCta: tag("secondaryCta"),
220
+ }}
221
+ />
222
+ );
223
+
224
+ const primaryWrapper = container.querySelector(".primary-cta");
225
+ const secondaryWrapper = container.querySelector(".secondary-cta");
226
+ expect(primaryWrapper).toHaveAttribute("data-contentful-field-id", "cta");
227
+ expect(secondaryWrapper).toHaveAttribute(
228
+ "data-contentful-field-id",
229
+ "secondaryCta"
230
+ );
231
+ expect(primaryWrapper).toHaveAttribute(
232
+ "data-contentful-entry-id",
233
+ "image-promo"
234
+ );
235
+ expect(secondaryWrapper).toHaveAttribute(
236
+ "data-contentful-entry-id",
237
+ "image-promo"
238
+ );
239
+ expect(primaryWrapper?.querySelector("button")).toHaveTextContent(
240
+ "Check availability"
241
+ );
242
+ expect(secondaryWrapper?.querySelector("button")).toHaveTextContent(
243
+ "Check availability"
244
+ );
245
+ expect(primaryWrapper?.querySelector("button")).not.toHaveAttribute(
246
+ "data-contentful-field-id",
247
+ "primaryButton"
248
+ );
249
+ expect(secondaryWrapper?.querySelector("button")).not.toHaveAttribute(
250
+ "data-contentful-field-id",
251
+ "secondaryButton"
252
+ );
253
+ });
254
+
97
255
  describe("Text content", () => {
98
256
  it("renders brow text when provided", () => {
99
257
  render(<ImagePromoBar {...defaultProps} brow="New" />);
@@ -37,6 +37,7 @@ export const ImagePromoBar: React.FC<ImagePromoBarProps> = ({
37
37
  onVideoEvent,
38
38
  renderCheckPlans,
39
39
  enableAnimation = false,
40
+ fieldProps,
40
41
  }) => {
41
42
  const hasVideoLink = Boolean(videoLink);
42
43
 
@@ -56,6 +57,7 @@ export const ImagePromoBar: React.FC<ImagePromoBarProps> = ({
56
57
  <Text
57
58
  as="div"
58
59
  className="subheading4 mb-4 text-text-brand xl:subheading2 xl:mb-3"
60
+ {...fieldProps?.brow}
59
61
  >
60
62
  {brow}
61
63
  </Text>
@@ -64,6 +66,7 @@ export const ImagePromoBar: React.FC<ImagePromoBarProps> = ({
64
66
  <Text
65
67
  as={enableHeading ? "h1" : "h2"}
66
68
  className="heading2 xl:heading1"
69
+ {...fieldProps?.title}
67
70
  >
68
71
  {title}
69
72
  </Text>
@@ -72,6 +75,7 @@ export const ImagePromoBar: React.FC<ImagePromoBarProps> = ({
72
75
  <Text
73
76
  as={enableHeading ? "h2" : "h3"}
74
77
  className="subheading3 mt-3 md:subheading1"
78
+ {...fieldProps?.subTitle}
75
79
  >
76
80
  {subTitle}
77
81
  </Text>
@@ -79,45 +83,54 @@ export const ImagePromoBar: React.FC<ImagePromoBarProps> = ({
79
83
  </div>
80
84
  {/* Content Section */}
81
85
  {description && (
82
- <Text as="div" className="body1">
86
+ <Text as="div" className="body1" {...fieldProps?.description}>
83
87
  {description}
84
88
  </Text>
85
89
  )}
86
90
  {/* Checklist Rendering */}
87
91
  {checklist.length > 0 && (
88
- <Checklist
89
- items={checklist}
90
- iconPosition="top"
91
- iconSize={24}
92
- listItemClassName="body1 text-text"
93
- listContainerClassName="mt-0 space-y-0 flex flex-col gap-3"
94
- />
92
+ <div {...fieldProps?.checklist}>
93
+ <Checklist
94
+ items={checklist}
95
+ iconPosition="top"
96
+ iconSize={24}
97
+ listItemClassName="body1 text-text"
98
+ listContainerClassName="mt-0 space-y-0 flex flex-col gap-3"
99
+ />
100
+ </div>
101
+ )}
102
+ {ctaDisclaimer && (
103
+ <div {...fieldProps?.ctaDisclaimer}>{ctaDisclaimer}</div>
95
104
  )}
96
- {ctaDisclaimer && <div>{ctaDisclaimer}</div>}
97
105
  {imageLinks.length > 0 && (
98
- <div className="flex gap-4">
106
+ <div className="flex gap-4" {...fieldProps?.imageLinks}>
99
107
  {/* Image Links Collection */}
100
- {imageLinks?.map(
101
- (link: { url: string; image: string }, index: number) => (
102
- <div key={index} className="image-link w-[147px]">
103
- <Link
104
- variant="unstyled"
105
- href={link.url}
106
- target="_blank"
107
- rel="noopener noreferrer"
108
- >
109
- <Image src={link.image} alt="icon-link" />
110
- </Link>
111
- </div>
112
- )
113
- )}
108
+ {imageLinks?.map((link, index: number) => (
109
+ <div
110
+ key={index}
111
+ className="image-link w-[147px]"
112
+ {...link.fieldProps?.image}
113
+ >
114
+ <Link
115
+ variant="unstyled"
116
+ href={link.url}
117
+ target="_blank"
118
+ rel="noopener noreferrer"
119
+ >
120
+ <Image src={link.image} alt="icon-link" />
121
+ </Link>
122
+ </div>
123
+ ))}
114
124
  </div>
115
125
  )}
116
126
  {/* CTAs and Disclaimers */}
117
127
  {(cta || secondaryCta) && (
118
128
  <div className="flex w-full flex-col gap-4 xl:flex-row">
119
129
  {cta && (
120
- <div className="primary-cta w-full xl:w-auto xl:shrink-0">
130
+ <div
131
+ className="primary-cta w-full xl:w-auto xl:shrink-0"
132
+ {...fieldProps?.cta}
133
+ >
121
134
  <Button
122
135
  {...cta}
123
136
  // Block-level `enableAnimation` takes precedence:
@@ -134,11 +147,15 @@ export const ImagePromoBar: React.FC<ImagePromoBarProps> = ({
134
147
  renderCheckPlans={renderCheckPlans}
135
148
  onModalButtonClick={onModalButtonClick}
136
149
  onClick={(e: React.MouseEvent) => onClick?.(e, "primary")}
150
+ fieldProps={cta.fieldProps}
137
151
  />
138
152
  </div>
139
153
  )}
140
154
  {secondaryCta && (
141
- <div className="secondary-cta w-full xl:w-auto xl:shrink-0">
155
+ <div
156
+ className="secondary-cta w-full xl:w-auto xl:shrink-0"
157
+ {...fieldProps?.secondaryCta}
158
+ >
142
159
  <Button
143
160
  {...secondaryCta}
144
161
  // Block-level `enableAnimation` takes precedence:
@@ -158,6 +175,7 @@ export const ImagePromoBar: React.FC<ImagePromoBarProps> = ({
158
175
  onClick={(e: React.MouseEvent) =>
159
176
  onClick?.(e, "secondary")
160
177
  }
178
+ fieldProps={secondaryCta.fieldProps}
161
179
  />
162
180
  </div>
163
181
  )}
@@ -209,7 +227,7 @@ export const ImagePromoBar: React.FC<ImagePromoBarProps> = ({
209
227
 
210
228
  {disclaimer && (
211
229
  <div className="mt-10 flex justify-center xl:mt-18">
212
- <div>{disclaimer}</div>
230
+ <div {...fieldProps?.disclaimer}>{disclaimer}</div>
213
231
  </div>
214
232
  )}
215
233
  </div>
@@ -4,6 +4,7 @@ import {
4
4
  VideoAnalyticsHandler,
5
5
  VideoLinkProps,
6
6
  } from "@shared/components/video-link";
7
+ import type { FieldProps } from "@shared/types/field-props";
7
8
  import { CheckPlansProps } from "@shared/types/micro-components";
8
9
 
9
10
  export type ImagePromoBarProps = {
@@ -22,7 +23,11 @@ export type ImagePromoBarProps = {
22
23
  openDescriptionLinksOnANewTab: boolean;
23
24
  checklist: string[];
24
25
  disclaimer: React.ReactNode;
25
- imageLinks: { url: string; image: string }[];
26
+ imageLinks: {
27
+ url: string;
28
+ image: string;
29
+ fieldProps?: FieldProps<"image">;
30
+ }[];
26
31
  cta: any;
27
32
  secondaryCta: any;
28
33
  ctaDisclaimer: React.ReactNode;
@@ -34,4 +39,17 @@ export type ImagePromoBarProps = {
34
39
  onModalButtonClick?: (id?: string) => void;
35
40
  onClick?: (event: React.MouseEvent, source: "primary" | "secondary") => void;
36
41
  renderCheckPlans?: (overrides?: CheckPlansProps) => React.ReactNode;
42
+ /** Consumer-supplied DOM attributes for the rendered content fields. */
43
+ fieldProps?: FieldProps<
44
+ | "brow"
45
+ | "title"
46
+ | "subTitle"
47
+ | "description"
48
+ | "checklist"
49
+ | "ctaDisclaimer"
50
+ | "cta"
51
+ | "secondaryCta"
52
+ | "disclaimer"
53
+ | "imageLinks"
54
+ >;
37
55
  };
@@ -1,6 +1,7 @@
1
1
  import React from "react";
2
2
  import { PrimaryHero } from "./index";
3
3
 
4
+ import { replaceTokens } from "@shared/utils/replace-tokens";
4
5
  import { fireEvent, render, screen } from "@testing-library/react";
5
6
 
6
7
  jest.mock("@shared/components/checklist", () => ({
@@ -26,8 +27,10 @@ jest.mock("@shared/components/next-image", () => ({
26
27
  }));
27
28
 
28
29
  jest.mock("@shared/components/text", () => ({
29
- Text: ({ as: Tag = "span", children, className }: any) => (
30
- <Tag className={className}>{children}</Tag>
30
+ Text: ({ as: Tag = "span", children, className, ...rest }: any) => (
31
+ <Tag className={className} {...rest}>
32
+ {children}
33
+ </Tag>
31
34
  ),
32
35
  }));
33
36
 
@@ -80,10 +83,25 @@ describe("PrimaryHero", () => {
80
83
  });
81
84
 
82
85
  describe("Price section", () => {
86
+ const getPriceLockup = (container: HTMLElement) => {
87
+ const amount = container.querySelector("p.subheading5");
88
+ const lockup = amount?.parentElement;
89
+ const [dollarSign, , suffix] = Array.from(lockup?.children ?? []);
90
+ return { lockup, dollarSign, amount, suffix };
91
+ };
92
+
93
+ const textExposedToAssistiveTech = (container: HTMLElement) => {
94
+ const clone = container.cloneNode(true) as HTMLElement;
95
+ clone
96
+ .querySelectorAll('[aria-hidden="true"]')
97
+ .forEach(node => node.remove());
98
+ return clone.textContent ?? "";
99
+ };
100
+
83
101
  it("renders price with dollar sign", () => {
84
- render(<PrimaryHero {...baseProps} price="49.99" />);
102
+ render(<PrimaryHero {...baseProps} price="49" />);
85
103
  expect(screen.getByText("$")).toBeInTheDocument();
86
- expect(screen.getByText("49.99")).toBeInTheDocument();
104
+ expect(screen.getByText("49")).toBeInTheDocument();
87
105
  });
88
106
 
89
107
  it("renders priceSuffix when provided", () => {
@@ -117,6 +135,247 @@ describe("PrimaryHero", () => {
117
135
  render(<PrimaryHero {...baseProps} />);
118
136
  expect(screen.queryByText("$")).not.toBeInTheDocument();
119
137
  });
138
+
139
+ it("keeps the whole-dollar authored lockup unchanged", () => {
140
+ const { container } = render(
141
+ <PrimaryHero {...baseProps} price="19" priceSuffix="99/mo*" />
142
+ );
143
+ const { lockup, dollarSign, amount, suffix } = getPriceLockup(container);
144
+
145
+ expect(lockup?.children).toHaveLength(3);
146
+ expect(dollarSign).toHaveTextContent(/^\$$/);
147
+ expect(dollarSign).toHaveClass("subheading1", "mt-0.5");
148
+ expect(amount).toHaveTextContent(/^19$/);
149
+ expect(suffix?.tagName).toBe("P");
150
+ expect(suffix).toHaveClass("subheading1", "mt-0.5");
151
+ expect(suffix).toHaveTextContent(/^99\/mo\*$/);
152
+ expect(suffix?.querySelector("span")).toBeNull();
153
+ });
154
+
155
+ it("renders decimal cents with the suffix so it matches the authored lockup", () => {
156
+ const authored = render(
157
+ <PrimaryHero {...baseProps} price="19" priceSuffix="99/mo*" />
158
+ );
159
+ const expected = getPriceLockup(authored.container);
160
+ const expectedLockup = {
161
+ amount: expected.amount?.textContent,
162
+ amountClass: expected.amount?.className,
163
+ suffix: expected.suffix?.textContent,
164
+ suffixClass: expected.suffix?.className,
165
+ count: expected.lockup?.children.length,
166
+ };
167
+ authored.unmount();
168
+
169
+ const { container } = render(
170
+ <PrimaryHero {...baseProps} price="19.99" priceSuffix="/mo*" />
171
+ );
172
+ const actual = getPriceLockup(container);
173
+
174
+ expect({
175
+ amount: actual.amount?.textContent,
176
+ amountClass: actual.amount?.className,
177
+ suffix: actual.suffix?.textContent,
178
+ suffixClass: actual.suffix?.className,
179
+ count: actual.lockup?.children.length,
180
+ }).toEqual(expectedLockup);
181
+ expect(screen.queryByText("19.99")).not.toBeInTheDocument();
182
+ });
183
+
184
+ it("renders a resolved price token like the authored production lockup", () => {
185
+ const tokens = [
186
+ { token: "price_fiber_300", value: "24.99", promo: "19.99" },
187
+ ];
188
+ const { container } = render(
189
+ <PrimaryHero
190
+ {...baseProps}
191
+ price={replaceTokens("${price_fiber_300}", tokens)}
192
+ priceSuffix={replaceTokens("/mo*", tokens)}
193
+ priceCallout="fiber plans|start at "
194
+ />
195
+ );
196
+ const { amount, suffix } = getPriceLockup(container);
197
+
198
+ expect(amount).toHaveTextContent(/^19$/);
199
+ expect(suffix).toHaveTextContent(/^99\/mo\*$/);
200
+ expect(screen.getByText("fiber plans")).toBeInTheDocument();
201
+ expect(screen.getByText("$19.99/mo*")).toHaveClass("sr-only");
202
+ });
203
+
204
+ it("renders decimal cents on their own when no suffix is provided", () => {
205
+ const { container } = render(
206
+ <PrimaryHero {...baseProps} price="39.99" />
207
+ );
208
+ const { amount, suffix } = getPriceLockup(container);
209
+
210
+ expect(amount).toHaveTextContent(/^39$/);
211
+ expect(suffix).toHaveClass("subheading1", "mt-0.5");
212
+ expect(suffix).toHaveTextContent(/^99$/);
213
+ });
214
+
215
+ it("splits prices with thousands separators", () => {
216
+ const { container } = render(
217
+ <PrimaryHero {...baseProps} price="1,299.50" priceSuffix="/yr" />
218
+ );
219
+ const { amount, suffix } = getPriceLockup(container);
220
+
221
+ expect(amount).toHaveTextContent(/^1,299$/);
222
+ expect(suffix).toHaveTextContent(/^50\/yr$/);
223
+ });
224
+
225
+ it.each(["Free", "$19.99", "19.", ".99"])(
226
+ "renders non-decimal price %p verbatim",
227
+ price => {
228
+ const { container } = render(
229
+ <PrimaryHero {...baseProps} price={price} priceSuffix="/mo" />
230
+ );
231
+ const { amount, suffix } = getPriceLockup(container);
232
+
233
+ expect(amount?.textContent).toBe(price);
234
+ expect(suffix).toHaveTextContent(/^\/mo$/);
235
+ }
236
+ );
237
+
238
+ it.each([
239
+ ["price", { price: "${price_tier3_2gig}" }],
240
+ ["priceSuffix", { price: "19", priceSuffix: "${price_suffix}" }],
241
+ ["priceCallout", { price: "19", priceCallout: "save $${reward_x}" }],
242
+ ])("hides the price lockup when %s has an unresolved token", (_, props) => {
243
+ const { container } = render(
244
+ <PrimaryHero
245
+ {...baseProps}
246
+ priceSuffix="/mo*"
247
+ priceCallout="fiber plans|start at "
248
+ {...props}
249
+ />
250
+ );
251
+
252
+ expect(container.querySelector("p.subheading5")).toBeNull();
253
+ expect(screen.queryByText("$")).not.toBeInTheDocument();
254
+ expect(screen.queryByText("fiber plans")).not.toBeInTheDocument();
255
+ expect(container.textContent).not.toContain("${");
256
+ });
257
+
258
+ it.each(["99/mo*", " 99/mo*", "99"])(
259
+ "does not repeat cents already leading the suffix %p",
260
+ priceSuffix => {
261
+ const { container } = render(
262
+ <PrimaryHero {...baseProps} price="59.99" priceSuffix={priceSuffix} />
263
+ );
264
+ const { amount, suffix } = getPriceLockup(container);
265
+
266
+ expect(amount).toHaveTextContent(/^59$/);
267
+ expect(suffix?.textContent).toBe(priceSuffix);
268
+ expect(suffix?.querySelector("span")).toBeNull();
269
+ }
270
+ );
271
+
272
+ it.each([
273
+ ["59.49", "99/mo*", "4999/mo*"],
274
+ ["19.99", "990/mo", "99990/mo"],
275
+ ])(
276
+ "keeps the cents when %p has a suffix %p that does not lead with them",
277
+ (price, priceSuffix, expected) => {
278
+ const { container } = render(
279
+ <PrimaryHero {...baseProps} price={price} priceSuffix={priceSuffix} />
280
+ );
281
+ const { suffix } = getPriceLockup(container);
282
+
283
+ expect(suffix?.textContent).toBe(expected);
284
+ }
285
+ );
286
+
287
+ it("tags split cents with the price field and the suffix with the priceSuffix field", () => {
288
+ const { container } = render(
289
+ <PrimaryHero
290
+ {...baseProps}
291
+ price="19.99"
292
+ priceSuffix="/mo*"
293
+ fieldProps={{
294
+ price: { "data-field": "price" },
295
+ priceSuffix: { "data-field": "priceSuffix" },
296
+ }}
297
+ />
298
+ );
299
+ const { amount, suffix } = getPriceLockup(container);
300
+ const cents = suffix?.querySelector("span");
301
+
302
+ expect(amount).toHaveAttribute("data-field", "price");
303
+ expect(suffix).toHaveAttribute("data-field", "priceSuffix");
304
+ expect(cents).toHaveAttribute("data-field", "price");
305
+ expect(cents).toHaveTextContent(/^99$/);
306
+ });
307
+
308
+ it.each([
309
+ ["19.99", "/mo*", "$19.99/mo*"],
310
+ ["59.99", "99/mo*", "$59.99/mo*"],
311
+ ["59.99", " 99/mo*", "$59.99/mo*"],
312
+ ["39.99", undefined, "$39.99"],
313
+ ["1,299.50", "/yr", "$1,299.50/yr"],
314
+ ["19.99", " per month", "$19.99 per month"],
315
+ ["59.49", "99/mo*", "$59.49 99/mo*"],
316
+ ])(
317
+ "exposes %p + %p to assistive technology as %p only once",
318
+ (price, priceSuffix, expected) => {
319
+ const { container } = render(
320
+ <PrimaryHero
321
+ {...baseProps}
322
+ price={price}
323
+ priceSuffix={priceSuffix}
324
+ priceCallout="fiber plans|start at "
325
+ />
326
+ );
327
+ const { lockup } = getPriceLockup(container);
328
+ const accessibleText = textExposedToAssistiveTech(container);
329
+
330
+ expect(screen.getByText(expected)).toHaveClass("sr-only");
331
+ expect(lockup).toHaveAttribute("aria-hidden", "true");
332
+ expect(accessibleText).toContain(expected);
333
+ expect(accessibleText.replace(expected, "")).not.toMatch(/\d/);
334
+ }
335
+ );
336
+
337
+ it("keeps the split visual fragments in the DOM for crawlers", () => {
338
+ const { container } = render(
339
+ <PrimaryHero {...baseProps} price="19.99" priceSuffix="/mo*" />
340
+ );
341
+ const { dollarSign, amount, suffix } = getPriceLockup(container);
342
+
343
+ expect(dollarSign).toBeInTheDocument();
344
+ expect(amount).toHaveTextContent(/^19$/);
345
+ expect(suffix).toHaveTextContent(/^99\/mo\*$/);
346
+ });
347
+
348
+ it.each([
349
+ ["19", "99/mo*"],
350
+ ["Free", "/mo"],
351
+ ["19.", "/mo"],
352
+ ])(
353
+ "leaves the unsplit lockup %p + %p exposed to assistive technology as before",
354
+ (price, priceSuffix) => {
355
+ const { container } = render(
356
+ <PrimaryHero {...baseProps} price={price} priceSuffix={priceSuffix} />
357
+ );
358
+ const { lockup } = getPriceLockup(container);
359
+
360
+ expect(container.querySelector(".sr-only")).toBeNull();
361
+ expect(lockup).not.toHaveAttribute("aria-hidden");
362
+ expect(textExposedToAssistiveTech(container)).toContain(
363
+ `$${price}${priceSuffix}`
364
+ );
365
+ }
366
+ );
367
+
368
+ it("does not expose an accessible price when the lockup is hidden", () => {
369
+ const { container } = render(
370
+ <PrimaryHero
371
+ {...baseProps}
372
+ price="${price_tier3_2gig}"
373
+ priceSuffix="/mo*"
374
+ />
375
+ );
376
+
377
+ expect(container.querySelector(".sr-only")).toBeNull();
378
+ });
120
379
  });
121
380
 
122
381
  describe("Checklist", () => {