@windstream/react-shared-components 0.2.92 → 0.2.94

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 (72) hide show
  1. package/dist/contentful/index.d.ts +46 -9
  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 +3 -3
  5. package/dist/contentful/index.js.map +1 -1
  6. package/dist/core.d.ts +6 -3
  7. package/dist/index.d.ts +4 -1
  8. package/dist/index.esm.js +2 -2
  9. package/dist/index.esm.js.map +1 -1
  10. package/dist/index.js +2 -2
  11. package/dist/index.js.map +1 -1
  12. package/dist/utils/index.d.ts +2 -1
  13. package/dist/utils/index.esm.js +1 -1
  14. package/dist/utils/index.esm.js.map +1 -1
  15. package/dist/utils/index.js +1 -1
  16. package/dist/utils/index.js.map +1 -1
  17. package/package.json +1 -1
  18. package/src/components/checklist/index.test.tsx +18 -1
  19. package/src/components/checklist/index.tsx +25 -21
  20. package/src/components/checklist/types.ts +8 -2
  21. package/src/contentful/blocks/address-input-banner/index.test.tsx +14 -0
  22. package/src/contentful/blocks/address-input-banner/index.tsx +3 -1
  23. package/src/contentful/blocks/address-input-banner/types.ts +1 -1
  24. package/src/contentful/blocks/anchored-bottom-banner/index.test.tsx +91 -0
  25. package/src/contentful/blocks/anchored-bottom-banner/index.tsx +18 -11
  26. package/src/contentful/blocks/anchored-bottom-banner/types.ts +17 -3
  27. package/src/contentful/blocks/blogs-grid/index.test.tsx +21 -1
  28. package/src/contentful/blocks/blogs-grid/index.tsx +1 -0
  29. package/src/contentful/blocks/blogs-grid/types.ts +3 -0
  30. package/src/contentful/blocks/blogs-grid-base/index.test.tsx +49 -3
  31. package/src/contentful/blocks/blogs-grid-base/index.tsx +11 -2
  32. package/src/contentful/blocks/blogs-grid-base/types.ts +7 -1
  33. package/src/contentful/blocks/cards/blog-card/index.test.tsx +29 -0
  34. package/src/contentful/blocks/cards/blog-card/index.tsx +18 -4
  35. package/src/contentful/blocks/cards/blog-card/types.ts +3 -1
  36. package/src/contentful/blocks/cards/product-card/index.test.tsx +43 -0
  37. package/src/contentful/blocks/cards/product-card/index.tsx +37 -9
  38. package/src/contentful/blocks/cards/product-card/types.ts +3 -0
  39. package/src/contentful/blocks/carousel/helper.test.tsx +13 -0
  40. package/src/contentful/blocks/carousel/helper.tsx +1 -0
  41. package/src/contentful/blocks/carousel/types.ts +2 -0
  42. package/src/contentful/blocks/dynamic-form/conditional-fields.tsx +1 -0
  43. package/src/contentful/blocks/dynamic-form/form-field.tsx +1 -0
  44. package/src/contentful/blocks/dynamic-form/index.test.tsx +48 -0
  45. package/src/contentful/blocks/dynamic-form/index.tsx +19 -7
  46. package/src/contentful/blocks/dynamic-form/render.tsx +13 -7
  47. package/src/contentful/blocks/dynamic-form/types.ts +28 -2
  48. package/src/contentful/blocks/dynamic-tabs/index.test.tsx +52 -2
  49. package/src/contentful/blocks/dynamic-tabs/index.tsx +9 -2
  50. package/src/contentful/blocks/dynamic-tabs/ordered-tab-views.tsx +16 -6
  51. package/src/contentful/blocks/dynamic-tabs/tab-views.tsx +10 -6
  52. package/src/contentful/blocks/dynamic-tabs/types.ts +6 -0
  53. package/src/contentful/blocks/email-input-block/index.test.tsx +51 -3
  54. package/src/contentful/blocks/email-input-block/index.tsx +88 -31
  55. package/src/contentful/blocks/email-input-block/types.ts +14 -1
  56. package/src/contentful/blocks/fiber-form/index.test.tsx +40 -2
  57. package/src/contentful/blocks/fiber-form/index.tsx +32 -13
  58. package/src/contentful/blocks/fiber-form/types.ts +18 -0
  59. package/src/contentful/blocks/iframe-embed/index.test.tsx +44 -7
  60. package/src/contentful/blocks/iframe-embed/index.tsx +3 -2
  61. package/src/contentful/blocks/iframe-embed/types.ts +1 -0
  62. package/src/contentful/blocks/navigation/{Navigation.stories.tsx → navigation.stories.tsx} +1 -1
  63. package/src/contentful/blocks/ordered-list/index.test.tsx +54 -4
  64. package/src/contentful/blocks/ordered-list/index.tsx +14 -2
  65. package/src/contentful/blocks/ordered-list/types.ts +15 -2
  66. package/src/contentful/blocks/primary-hero/index.tsx +5 -5
  67. package/src/contentful/blocks/search-block/index.test.tsx +36 -2
  68. package/src/contentful/blocks/search-block/index.tsx +24 -4
  69. package/src/contentful/blocks/search-block/types.ts +10 -0
  70. package/src/utils/index.ts +1 -1
  71. package/src/utils/replace-tokens.test.ts +40 -0
  72. package/src/utils/replace-tokens.ts +13 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windstream/react-shared-components",
3
- "version": "0.2.92",
3
+ "version": "0.2.94",
4
4
  "type": "module",
5
5
  "description": "Shared React components for Kinetic applications",
6
6
  "main": "dist/index.js",
@@ -8,11 +8,13 @@ jest.mock("@shared/components/list", () => ({
8
8
  List: ({
9
9
  children,
10
10
  className,
11
+ ...rest
11
12
  }: {
12
13
  children: React.ReactNode;
13
14
  className?: string;
15
+ [key: string]: unknown;
14
16
  }) => (
15
- <ul data-testid="list" className={className}>
17
+ <ul data-testid="list" className={className} {...rest}>
16
18
  {children}
17
19
  </ul>
18
20
  ),
@@ -117,6 +119,21 @@ describe("Checklist", () => {
117
119
  });
118
120
 
119
121
  describe("fieldProps", () => {
122
+ it("applies the items fieldProps to the list", () => {
123
+ render(
124
+ <Checklist
125
+ items={["One"]}
126
+ fieldProps={{
127
+ items: { "data-contentful-field-id": "checklist-items" },
128
+ }}
129
+ />
130
+ );
131
+ expect(screen.getByTestId("list")).toHaveAttribute(
132
+ "data-contentful-field-id",
133
+ "checklist-items"
134
+ );
135
+ });
136
+
120
137
  it("spreads each structured item's fieldProps onto its own row", () => {
121
138
  render(
122
139
  <Checklist
@@ -31,33 +31,37 @@ export const Checklist: React.FC<ChecklistProps> = props => {
31
31
  iconPosition = "center",
32
32
  iconColor = "green",
33
33
  iconClassName,
34
+ fieldProps,
34
35
  } = props;
35
36
  const showIcons = listIconName !== "disc";
36
37
  if (!items?.length) return null;
37
38
 
38
- const renderContent = (content: React.ReactNode | string) => {
39
- if (
40
- typeof content === "string" &&
41
- content.includes("|") &&
42
- (content.match(/\|/g) || []).length % 2 === 0
43
- ) {
44
- const parts = content.split("|");
45
- const count = (content.match(/\|/g) || []).length;
46
- return parts.map((part, index) => {
47
- if (!part) return null;
48
- const isBold = count > 1 ? index % 2 === 1 : false;
49
- return isBold ? (
50
- <Text as="span" key={index} style={{ fontWeight: 900 }}>{part}</Text>
51
- ) : (
52
- <span key={index}>{part}</span>
53
- );
54
- });
55
- }
56
- return content;
57
- };
39
+ const renderContent = (content: React.ReactNode | string) => {
40
+ if (
41
+ typeof content === "string" &&
42
+ content.includes("|") &&
43
+ (content.match(/\|/g) || []).length % 2 === 0
44
+ ) {
45
+ const parts = content.split("|");
46
+ const count = (content.match(/\|/g) || []).length;
47
+ return parts.map((part, index) => {
48
+ if (!part) return null;
49
+ const isBold = count > 1 ? index % 2 === 1 : false;
50
+ return isBold ? (
51
+ <Text as="span" key={index} style={{ fontWeight: 900 }}>
52
+ {part}
53
+ </Text>
54
+ ) : (
55
+ <span key={index}>{part}</span>
56
+ );
57
+ });
58
+ }
59
+ return content;
60
+ };
58
61
 
59
62
  return (
60
63
  <List
64
+ {...fieldProps?.items}
61
65
  className={cx("mt-2 space-y-1", listContainerClassName)}
62
66
  variant={showIcons ? "unstyled" : "default"}
63
67
  >
@@ -105,7 +109,7 @@ export const Checklist: React.FC<ChecklistProps> = props => {
105
109
  />
106
110
  </div>
107
111
  ) : null}
108
- <Text as="div">{renderContent(content)}</Text>
112
+ <Text as="div">{renderContent(content)}</Text>
109
113
  </ListItem>
110
114
  );
111
115
  })}
@@ -1,5 +1,9 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import type { FieldProps } from "@shared/types/field-props";
4
+
1
5
  export type ChecklistItem = {
2
- title: React.ReactNode;
6
+ title: ReactNode;
3
7
  iconUrl?: string;
4
8
  iconAlt?: string;
5
9
  /** Extra DOM attribute props, spread onto this item's list row. */
@@ -7,7 +11,7 @@ export type ChecklistItem = {
7
11
  };
8
12
 
9
13
  export type ChecklistProps = {
10
- items: (React.ReactNode | ChecklistItem)[];
14
+ items: (ReactNode | ChecklistItem)[];
11
15
  // TODO: add icon names as needed
12
16
  listIconName?: "check" | "disc";
13
17
  listItemClassName?: string;
@@ -16,6 +20,8 @@ export type ChecklistProps = {
16
20
  iconSize?: 20 | 24 | 32 | 40 | 48 | 52 | undefined;
17
21
  iconPosition?: "top" | "center";
18
22
  iconColor?: "green" | "yellow" | "blue";
23
+ /** Inspector attributes for the checklist items field. */
24
+ fieldProps?: FieldProps<"items">;
19
25
  };
20
26
 
21
27
  export const iconColors = {
@@ -27,6 +27,20 @@ describe("AddressInputBanner", () => {
27
27
  );
28
28
  });
29
29
 
30
+ it("tags the custom check-plans CTA region", () => {
31
+ render(
32
+ <AddressInputBanner
33
+ {...defaultProps}
34
+ renderCheckPlans={() => <button>Check plans now</button>}
35
+ fieldProps={{ cta: { "data-contentful-field-id": "cta" } }}
36
+ />
37
+ );
38
+ expect(screen.getByText("Check plans now").parentElement).toHaveAttribute(
39
+ "data-contentful-field-id",
40
+ "cta"
41
+ );
42
+ });
43
+
30
44
  it("returns null when isVisible is false", () => {
31
45
  const { container } = render(
32
46
  <AddressInputBanner {...defaultProps} isVisible={false} />
@@ -50,7 +50,9 @@ export const AddressInputBanner: FC<AddressInputBannerProps> = props => {
50
50
  >
51
51
  {title}
52
52
  </Text>
53
- {renderedCheckPlans ? <div>{renderedCheckPlans}</div> : null}
53
+ {renderedCheckPlans ? (
54
+ <div {...fieldProps?.cta}>{renderedCheckPlans}</div>
55
+ ) : null}
54
56
  </div>
55
57
  );
56
58
  };
@@ -13,5 +13,5 @@ export interface AddressInputBannerProps {
13
13
  isVisible?: boolean;
14
14
  renderCheckPlans?: (overrides?: CheckPlansProps) => React.ReactNode;
15
15
  /** Extra DOM attribute props, spread onto the respective rendered elements. */
16
- fieldProps?: FieldProps<"title">;
16
+ fieldProps?: FieldProps<"title" | "cta">;
17
17
  }
@@ -65,6 +65,41 @@ describe("AnchoredBottomBanner", () => {
65
65
  );
66
66
  });
67
67
 
68
+ it("tags the default banner entry, icon, timer, and linked button label", () => {
69
+ render(
70
+ <AnchoredBottomBanner
71
+ enableCountdownTimer={true}
72
+ countdownEndDateTime="2026-05-19T13:00:00Z"
73
+ iconName="local_offer"
74
+ ctaButtonLabel="Shop now"
75
+ fieldProps={{
76
+ entry: { "data-contentful-entry-id": "banner-entry" },
77
+ iconName: { "data-contentful-field-id": "iconName" },
78
+ countdownEndDateTime: {
79
+ "data-contentful-field-id": "countdownEndDateTime",
80
+ },
81
+ buttonLabel: { "data-contentful-field-id": "buttonLabel" },
82
+ }}
83
+ />
84
+ );
85
+
86
+ expect(screen.getByText("Shop now")).toHaveAttribute(
87
+ "data-contentful-field-id",
88
+ "buttonLabel"
89
+ );
90
+ expect(screen.getByText("local_offer").parentElement).toHaveAttribute(
91
+ "data-contentful-field-id",
92
+ "iconName"
93
+ );
94
+ expect(screen.getByText(/01H : 00M : 00/)).toHaveAttribute(
95
+ "data-contentful-field-id",
96
+ "countdownEndDateTime"
97
+ );
98
+ expect(
99
+ document.querySelector('[data-contentful-entry-id="banner-entry"]')
100
+ ).toBeInTheDocument();
101
+ });
102
+
68
103
  it("uses active scheduled-message fieldProps for its CTA content", () => {
69
104
  render(
70
105
  <AnchoredBottomBanner
@@ -387,6 +422,62 @@ describe("AnchoredBottomBanner scheduledMessages", () => {
387
422
  expect(screen.getByText(/01H : 00M : 00/)).toBeInTheDocument();
388
423
  });
389
424
 
425
+ it("uses the active scheduled-message entry tags for banner content", () => {
426
+ render(
427
+ <AnchoredBottomBanner
428
+ enableCountdownTimer={true}
429
+ countdownEndDateTime="2026-05-19T14:00:00Z"
430
+ iconName="local_offer"
431
+ ctaButtonLabel="Default label"
432
+ fieldProps={{
433
+ entry: { "data-contentful-entry-id": "default-entry" },
434
+ iconName: { "data-contentful-field-id": "default-icon" },
435
+ countdownEndDateTime: {
436
+ "data-contentful-field-id": "default-countdown",
437
+ },
438
+ buttonLabel: { "data-contentful-field-id": "default-label" },
439
+ }}
440
+ scheduledMessages={[
441
+ {
442
+ scheduledStart: "2026-05-18",
443
+ scheduleEnd: "2026-05-20",
444
+ enableCountdownTimer: true,
445
+ countdownEndDateTime: "2026-05-19T13:00:00Z",
446
+ iconName: "bolt",
447
+ ctaButtonLabel: "Flash sale",
448
+ fieldProps: {
449
+ entry: { "data-contentful-entry-id": "scheduled-entry" },
450
+ iconName: { "data-contentful-field-id": "scheduled-icon" },
451
+ countdownEndDateTime: {
452
+ "data-contentful-field-id": "scheduled-countdown",
453
+ },
454
+ buttonLabel: { "data-contentful-field-id": "scheduled-label" },
455
+ },
456
+ },
457
+ ]}
458
+ />
459
+ );
460
+
461
+ expect(screen.getByText("Flash sale")).toHaveAttribute(
462
+ "data-contentful-field-id",
463
+ "scheduled-label"
464
+ );
465
+ expect(screen.getByText("bolt").parentElement).toHaveAttribute(
466
+ "data-contentful-field-id",
467
+ "scheduled-icon"
468
+ );
469
+ expect(screen.getByText(/01H : 00M : 00/)).toHaveAttribute(
470
+ "data-contentful-field-id",
471
+ "scheduled-countdown"
472
+ );
473
+ expect(
474
+ document.querySelector('[data-contentful-entry-id="scheduled-entry"]')
475
+ ).toBeInTheDocument();
476
+ expect(
477
+ document.querySelector('[data-contentful-entry-id="default-entry"]')
478
+ ).not.toBeInTheDocument();
479
+ });
480
+
390
481
  it("treats scheduleEnd as inclusive of the whole end day", () => {
391
482
  render(
392
483
  <AnchoredBottomBanner
@@ -343,6 +343,7 @@ export const AnchoredBottomBanner: React.FC<AnchoredBottomBannerProps> = ({
343
343
  className={`fixed bottom-0 left-0 right-0 z-[30] flex w-full items-center justify-center px-4 py-3 transition-all duration-300 ${bgClass} ${
344
344
  boxShadow ? boxShadow : "shadow-[0_-4px_10px_rgba(0,0,0,0.1)]"
345
345
  }`}
346
+ {...effectiveFieldProps?.entry}
346
347
  >
347
348
  <Link
348
349
  href={effectiveCtaButtonLink || "#"}
@@ -354,28 +355,34 @@ export const AnchoredBottomBanner: React.FC<AnchoredBottomBannerProps> = ({
354
355
  className={`${textColorClass} break-words text-body1 font-black leading-snug md:text-[18px]`}
355
356
  >
356
357
  {effectiveIconName && (
357
- <MaterialIcon
358
- name={
359
- effectiveIconName as React.ComponentProps<
360
- typeof MaterialIcon
361
- >["name"]
362
- }
363
- size={24}
364
- fill={1}
365
- className={`${textColorClass} align-text-bottom`}
366
- />
358
+ <span {...effectiveFieldProps?.iconName}>
359
+ <MaterialIcon
360
+ name={
361
+ effectiveIconName as React.ComponentProps<
362
+ typeof MaterialIcon
363
+ >["name"]
364
+ }
365
+ size={24}
366
+ fill={1}
367
+ className={`${textColorClass} align-text-bottom`}
368
+ />
369
+ </span>
367
370
  )}
368
371
  {countdown.shouldShow && (
369
372
  <span
370
373
  className="inline-block whitespace-nowrap rounded-lg bg-white px-1 tabular-nums text-text"
371
374
  suppressHydrationWarning={true}
375
+ {...effectiveFieldProps?.countdownEndDateTime}
372
376
  >
373
377
  {countdown.text}
374
378
  </span>
375
379
  )}
376
380
  {countdown.shouldShow ? " " : null}
377
381
  {effectiveCtaButtonLabel && (
378
- <span {...effectiveFieldProps?.ctaButtonLabel}>
382
+ <span
383
+ {...(effectiveFieldProps?.buttonLabel ??
384
+ effectiveFieldProps?.ctaButtonLabel)}
385
+ >
379
386
  {effectiveCtaButtonLabel}
380
387
  </span>
381
388
  )}{" "}
@@ -51,7 +51,14 @@ export interface ScheduledMessage {
51
51
  ctaSuffixText?: string;
52
52
  ctaButtonLink?: string;
53
53
  ctaButtonTarget?: string;
54
- fieldProps?: FieldProps<"ctaButtonLabel" | "ctaSuffixText">;
54
+ fieldProps?: FieldProps<
55
+ | "entry"
56
+ | "iconName"
57
+ | "countdownEndDateTime"
58
+ | "buttonLabel"
59
+ | "ctaButtonLabel"
60
+ | "ctaSuffixText"
61
+ >;
55
62
  }
56
63
 
57
64
  export interface AnchoredBottomBannerProps {
@@ -80,6 +87,13 @@ export interface AnchoredBottomBannerProps {
80
87
  activeMessage?: ScheduledMessage;
81
88
  applicableWeekday: string;
82
89
  }) => void;
83
- /** DOM attributes for the rendered CTA label and suffix fields. */
84
- fieldProps?: FieldProps<"ctaButtonLabel" | "ctaSuffixText">;
90
+ /** DOM attributes for the banner entry and rendered content fields. */
91
+ fieldProps?: FieldProps<
92
+ | "entry"
93
+ | "iconName"
94
+ | "countdownEndDateTime"
95
+ | "buttonLabel"
96
+ | "ctaButtonLabel"
97
+ | "ctaSuffixText"
98
+ >;
85
99
  }
@@ -70,7 +70,7 @@ jest.mock("@shared/components/text", () => ({
70
70
  }));
71
71
 
72
72
  jest.mock("@shared/contentful/blocks/cards/blog-card", () => ({
73
- BlogCard: ({ href, title, category, image, index }: any) => (
73
+ BlogCard: ({ href, title, category, image, index, fieldProps }: any) => (
74
74
  <a
75
75
  href={href}
76
76
  data-testid={`blog-card-${index}`}
@@ -78,6 +78,7 @@ jest.mock("@shared/contentful/blocks/cards/blog-card", () => ({
78
78
  data-image-alt={image?.alt || ""}
79
79
  data-image-width={image?.width || ""}
80
80
  data-image-height={image?.height || ""}
81
+ data-field-props={JSON.stringify(fieldProps)}
81
82
  >
82
83
  {title} - {category}
83
84
  </a>
@@ -136,6 +137,25 @@ const defaultProps: BlogGridProps = {
136
137
  };
137
138
 
138
139
  describe("BlogGrid", () => {
140
+ it("forwards each article's inspector slots without tagging another article", () => {
141
+ const fields = { title: { "data-contentful-entry-id": "article-one" } };
142
+ render(
143
+ <BlogGrid
144
+ {...defaultProps}
145
+ paginatedArticles={[
146
+ { ...mockArticles[0], fieldProps: fields },
147
+ mockArticles[1],
148
+ ]}
149
+ />
150
+ );
151
+ expect(screen.getByTestId("blog-card-0")).toHaveAttribute(
152
+ "data-field-props",
153
+ JSON.stringify(fields)
154
+ );
155
+ expect(screen.getByTestId("blog-card-1")).not.toHaveAttribute(
156
+ "data-field-props"
157
+ );
158
+ });
139
159
  beforeEach(() => {
140
160
  jest.clearAllMocks();
141
161
  mockSearchParams = new URLSearchParams("");
@@ -128,6 +128,7 @@ export function BlogGrid({
128
128
  description={article.shortDescription}
129
129
  date={article.blogCreationDate}
130
130
  category={article.category}
131
+ fieldProps={article.fieldProps}
131
132
  image={coverImage}
132
133
  index={index}
133
134
  />
@@ -1,5 +1,8 @@
1
+ import type { BlogCardProps as CardProps } from "@shared/contentful/blocks/cards/blog-card/types";
2
+
1
3
  export interface BlogCardProps {
2
4
  slug: string;
5
+ fieldProps?: CardProps["fieldProps"];
3
6
  title?: string;
4
7
  shortDescription?: string;
5
8
  blogCreationDate?: string;
@@ -41,18 +41,29 @@ jest.mock("@shared/components/select", () => ({
41
41
  }));
42
42
 
43
43
  jest.mock("@shared/components/text", () => ({
44
- Text: ({ as: Tag = "span", children, className }: any) => (
45
- <Tag className={className}>{children}</Tag>
44
+ Text: ({ as: Tag = "span", children, className, ...rest }: any) => (
45
+ <Tag className={className} {...rest}>
46
+ {children}
47
+ </Tag>
46
48
  ),
47
49
  }));
48
50
 
49
51
  jest.mock("@shared/contentful/blocks/cards/blog-card", () => ({
50
- BlogCard: ({ href, title, category, image, imageComponent, index }: any) => (
52
+ BlogCard: ({
53
+ href,
54
+ title,
55
+ category,
56
+ image,
57
+ imageComponent,
58
+ index,
59
+ fieldProps,
60
+ }: any) => (
51
61
  <a
52
62
  href={href}
53
63
  data-testid={`blog-card-${index}`}
54
64
  data-has-image={!!image}
55
65
  data-has-image-component={!!imageComponent}
66
+ data-field-props={JSON.stringify(fieldProps)}
56
67
  >
57
68
  {title} - {category}
58
69
  </a>
@@ -108,6 +119,41 @@ const defaultProps: BlogGridBaseProps = {
108
119
  };
109
120
 
110
121
  describe("BlogGridBase", () => {
122
+ it("tags authored headings and forwards child article slots without tagging siblings", () => {
123
+ const articleFields = {
124
+ title: { "data-contentful-entry-id": "article-one" },
125
+ };
126
+ render(
127
+ <BlogGridBase
128
+ {...defaultProps}
129
+ title="Latest Stories"
130
+ description="Updates"
131
+ fieldProps={{
132
+ title: { "data-contentful-field-id": "recentArticlesTitle" },
133
+ description: { "data-contentful-field-id": "description" },
134
+ }}
135
+ paginatedArticles={[
136
+ { ...mockArticles[0], fieldProps: articleFields },
137
+ mockArticles[1],
138
+ ]}
139
+ />
140
+ );
141
+ expect(screen.getByText("Latest Stories")).toHaveAttribute(
142
+ "data-contentful-field-id",
143
+ "recentArticlesTitle"
144
+ );
145
+ expect(screen.getByText("Updates")).toHaveAttribute(
146
+ "data-contentful-field-id",
147
+ "description"
148
+ );
149
+ expect(screen.getByTestId("blog-card-0")).toHaveAttribute(
150
+ "data-field-props",
151
+ JSON.stringify(articleFields)
152
+ );
153
+ expect(screen.getByTestId("blog-card-1")).not.toHaveAttribute(
154
+ "data-field-props"
155
+ );
156
+ });
111
157
  beforeEach(() => jest.clearAllMocks());
112
158
 
113
159
  describe("Rendering", () => {
@@ -19,6 +19,7 @@ export function BlogGridBase({
19
19
  getPageHref,
20
20
  asList = false,
21
21
  imageComponent,
22
+ fieldProps,
22
23
  }: BlogGridBaseProps) {
23
24
  function handleCategoryChange(value: unknown) {
24
25
  if (!value || Array.isArray(value)) return;
@@ -39,11 +40,18 @@ export function BlogGridBase({
39
40
  {/* Controls bar */}
40
41
  <div className="mx-auto flex max-w-[1200px] flex-wrap items-center justify-between gap-3 px-6 pb-6">
41
42
  {description && (
42
- <Text as="p" className="body1 mb-4 w-full text-center md:mb-6">
43
+ <Text
44
+ as="p"
45
+ className="body1 mb-4 w-full text-center md:mb-6"
46
+ {...fieldProps?.description}
47
+ >
43
48
  {description}
44
49
  </Text>
45
50
  )}
46
- <Text className="w-full text-center text-heading5 font-black lowercase text-text-secondary md:w-auto md:text-left">
51
+ <Text
52
+ className="w-full text-center text-heading5 font-black lowercase text-text-secondary md:w-auto md:text-left"
53
+ {...fieldProps?.title}
54
+ >
47
55
  {title}
48
56
  </Text>
49
57
  {categoryOptions.length > 0 && (
@@ -105,6 +113,7 @@ export function BlogGridBase({
105
113
  description={article.shortDescription}
106
114
  date={article.blogCreationDate}
107
115
  category={article.category}
116
+ fieldProps={article.fieldProps}
108
117
  image={coverImage}
109
118
  imageComponent={imageComponent}
110
119
  asGrid={!asList}
@@ -1,9 +1,14 @@
1
1
  import React from "react";
2
2
 
3
- import { BlogCardImageProps } from "@shared/contentful/blocks/cards/blog-card/types";
3
+ import {
4
+ BlogCardImageProps,
5
+ BlogCardProps as CardProps,
6
+ } from "@shared/contentful/blocks/cards/blog-card/types";
7
+ import type { FieldProps } from "@shared/types/field-props";
4
8
 
5
9
  export interface BlogCardProps {
6
10
  slug: string;
11
+ fieldProps?: CardProps["fieldProps"];
7
12
  title?: string;
8
13
  shortDescription?: string;
9
14
  blogCreationDate?: string;
@@ -17,6 +22,7 @@ export interface BlogCardProps {
17
22
  }
18
23
 
19
24
  export interface BlogGridBaseProps {
25
+ fieldProps?: FieldProps<"title" | "description">;
20
26
  title?: string;
21
27
  description?: string;
22
28
  paginatedArticles: BlogCardProps[];
@@ -32,6 +32,35 @@ describe("BlogCard", () => {
32
32
  };
33
33
 
34
34
  describe("Rendering", () => {
35
+ it("tags each authored child field independently", () => {
36
+ const tag = (field: string) => ({ "data-contentful-field-id": field });
37
+ const { container } = render(
38
+ <BlogCard
39
+ {...defaultProps}
40
+ image={{ src: "/cover.jpg", alt: "Cover", width: 400, height: 300 }}
41
+ fieldProps={{
42
+ title: tag("title"),
43
+ description: tag("shortDescription"),
44
+ date: tag("blogCreationDate"),
45
+ category: tag("category"),
46
+ image: tag("cover"),
47
+ href: tag("slug"),
48
+ }}
49
+ />
50
+ );
51
+ for (const field of [
52
+ "title",
53
+ "shortDescription",
54
+ "blogCreationDate",
55
+ "category",
56
+ "cover",
57
+ "slug",
58
+ ]) {
59
+ expect(
60
+ container.querySelector(`[data-contentful-field-id="${field}"]`)
61
+ ).not.toBeNull();
62
+ }
63
+ });
35
64
  it("renders the article element", () => {
36
65
  const { container } = render(<BlogCard {...defaultProps} />);
37
66
  expect(container.querySelector("article")).toBeInTheDocument();
@@ -43,7 +43,10 @@ export const BlogCard: React.FC<BlogCardProps> = ({
43
43
  >
44
44
  {/* Image (hidden in list view) */}
45
45
  {asGrid && (
46
- <div className="block h-[232px] w-full flex-shrink-0 overflow-hidden bg-gray-100">
46
+ <div
47
+ className="block h-[232px] w-full flex-shrink-0 overflow-hidden bg-gray-100"
48
+ {...fieldProps?.image}
49
+ >
47
50
  {image ? (
48
51
  ImageComponent ? (
49
52
  <ImageComponent
@@ -82,7 +85,11 @@ export const BlogCard: React.FC<BlogCardProps> = ({
82
85
  >
83
86
  {/* Meta: category + date */}
84
87
  <div className="flex items-center gap-2 text-[13px]">
85
- {asGrid && <span className="body2 text-text-brand">{category}</span>}
88
+ {asGrid && (
89
+ <span className="body2 text-text-brand" {...fieldProps?.category}>
90
+ {category}
91
+ </span>
92
+ )}
86
93
  {date && (
87
94
  <>
88
95
  {asGrid && (
@@ -90,7 +97,9 @@ export const BlogCard: React.FC<BlogCardProps> = ({
90
97
  •
91
98
  </span>
92
99
  )}
93
- <time className="body2 text-text">{date}</time>
100
+ <time className="body2 text-text" {...fieldProps?.date}>
101
+ {date}
102
+ </time>
94
103
  </>
95
104
  )}
96
105
  </div>
@@ -100,6 +109,7 @@ export const BlogCard: React.FC<BlogCardProps> = ({
100
109
  <Text as="h2" className="heading6" {...fieldProps?.title}>
101
110
  <Link
102
111
  href={href}
112
+ {...fieldProps?.href}
103
113
  className="m-0 line-clamp-3 font-black transition-colors duration-150 after:absolute after:inset-0 after:z-10 after:content-[''] hover:text-text-brand group-hover:text-text-brand"
104
114
  >
105
115
  {title}
@@ -120,7 +130,11 @@ export const BlogCard: React.FC<BlogCardProps> = ({
120
130
 
121
131
  {/* Fallback stretched link keeps the card navigable when no title link is rendered */}
122
132
  {!title && (
123
- <Link href={href} className="absolute inset-0 z-10">
133
+ <Link
134
+ href={href}
135
+ className="absolute inset-0 z-10"
136
+ {...fieldProps?.href}
137
+ >
124
138
  <span className="sr-only">{category ?? "Read more"}</span>
125
139
  </Link>
126
140
  )}