@windstream/react-shared-components 0.2.80 → 0.2.82

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 (28) hide show
  1. package/dist/contentful/index.d.ts +11 -1
  2. package/dist/contentful/index.esm.js +2 -2
  3. package/dist/contentful/index.esm.js.map +1 -1
  4. package/dist/contentful/index.js +2 -2
  5. package/dist/contentful/index.js.map +1 -1
  6. package/dist/core.d.ts +1 -1
  7. package/dist/index.d.ts +1 -1
  8. package/package.json +1 -1
  9. package/src/contentful/blocks/address-input-banner/index.test.tsx +13 -0
  10. package/src/contentful/blocks/address-input-banner/index.tsx +5 -1
  11. package/src/contentful/blocks/address-input-banner/types.ts +3 -0
  12. package/src/contentful/blocks/anchored-bottom-banner/index.test.tsx +35 -0
  13. package/src/contentful/blocks/anchored-bottom-banner/index.tsx +32 -16
  14. package/src/contentful/blocks/anchored-bottom-banner/types.ts +18 -1
  15. package/src/contentful/blocks/button/index.test.tsx +45 -2
  16. package/src/contentful/blocks/button/index.tsx +13 -1
  17. package/src/contentful/blocks/button/types.ts +2 -0
  18. package/src/contentful/blocks/footer/index.test.tsx +65 -2
  19. package/src/contentful/blocks/footer/index.tsx +14 -4
  20. package/src/contentful/blocks/footer/types.ts +4 -0
  21. package/src/contentful/blocks/navigation/desktop-link-groups.tsx/index.test.tsx +26 -2
  22. package/src/contentful/blocks/navigation/desktop-link-groups.tsx/index.tsx +8 -2
  23. package/src/contentful/blocks/navigation/index.test.tsx +40 -2
  24. package/src/contentful/blocks/navigation/index.tsx +25 -5
  25. package/src/contentful/blocks/navigation/mobile-link-groups.tsx/index.test.tsx +14 -0
  26. package/src/contentful/blocks/navigation/mobile-link-groups.tsx/index.tsx +4 -1
  27. package/src/contentful/blocks/navigation/types.ts +5 -0
  28. package/src/types/micro-components.ts +6 -0
package/dist/core.d.ts CHANGED
@@ -423,7 +423,7 @@ type ButtonAsAnchor = ButtonCustomProps & Omit<AnchorHTMLAttributes<HTMLAnchorEl
423
423
  };
424
424
  type ButtonProps = ButtonAsButton | ButtonAsAnchor;
425
425
 
426
- declare const BrandButton: React__default.ForwardRefExoticComponent<ButtonProps$2 & React__default.RefAttributes<HTMLButtonElement | HTMLAnchorElement>>;
426
+ declare const BrandButton: React__default.ForwardRefExoticComponent<ButtonProps$2 & React__default.RefAttributes<HTMLAnchorElement | HTMLButtonElement>>;
427
427
 
428
428
  declare const Checklist: React__default.FC<ChecklistProps>;
429
429
 
package/dist/index.d.ts CHANGED
@@ -67,7 +67,7 @@ type InputFieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, "size"> & {
67
67
 
68
68
  declare const InputField: ForwardRefRenderFunction<HTMLInputElement, InputFieldProps>;
69
69
  declare const Input: React$1.ForwardRefExoticComponent<Omit<React$1.InputHTMLAttributes<HTMLInputElement>, "size"> & {
70
- state?: "error" | "default" | "focus" | "active" | "hover" | "filled" | undefined;
70
+ state?: "default" | "error" | "focus" | "active" | "hover" | "filled" | undefined;
71
71
  size?: "medium" | "slim" | "large" | undefined;
72
72
  label?: string | undefined;
73
73
  errorText?: string | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windstream/react-shared-components",
3
- "version": "0.2.80",
3
+ "version": "0.2.82",
4
4
  "type": "module",
5
5
  "description": "Shared React components for Kinetic applications",
6
6
  "main": "dist/index.js",
@@ -14,6 +14,19 @@ describe("AddressInputBanner", () => {
14
14
  expect(screen.getByText("Check availability")).toBeInTheDocument();
15
15
  });
16
16
 
17
+ it("spreads title fieldProps onto the title element", () => {
18
+ render(
19
+ <AddressInputBanner
20
+ {...defaultProps}
21
+ fieldProps={{ title: { "data-contentful-field-id": "title" } }}
22
+ />
23
+ );
24
+ expect(screen.getByText("Check availability")).toHaveAttribute(
25
+ "data-contentful-field-id",
26
+ "title"
27
+ );
28
+ });
29
+
17
30
  it("returns null when isVisible is false", () => {
18
31
  const { container } = render(
19
32
  <AddressInputBanner {...defaultProps} isVisible={false} />
@@ -19,6 +19,7 @@ export const AddressInputBanner: FC<AddressInputBannerProps> = props => {
19
19
  navHeight,
20
20
  isVisible,
21
21
  renderCheckPlans,
22
+ fieldProps,
22
23
  } = props;
23
24
 
24
25
  const style = variantStyles[variant?.toLowerCase()] || variantStyles.yellow;
@@ -43,7 +44,10 @@ export const AddressInputBanner: FC<AddressInputBannerProps> = props => {
43
44
  "flex flex-col items-center justify-center gap-3 p-[10px] lg:flex-row lg:gap-8 lg:px-6 lg:py-[10px]"
44
45
  )}
45
46
  >
46
- <Text className="label3 w-full text-center md:label1 lg:w-auto lg:text-left">
47
+ <Text
48
+ className="label3 w-full text-center md:label1 lg:w-auto lg:text-left"
49
+ {...fieldProps?.title}
50
+ >
47
51
  {title}
48
52
  </Text>
49
53
  {renderedCheckPlans ? <div>{renderedCheckPlans}</div> : null}
@@ -1,6 +1,7 @@
1
1
  import React from "react";
2
2
 
3
3
  import { ButtonProps } from "@shared/contentful/blocks/button/types";
4
+ import type { FieldProps } from "@shared/types/field-props";
4
5
  import { CheckPlansProps } from "@shared/types/micro-components";
5
6
 
6
7
  export interface AddressInputBannerProps {
@@ -11,4 +12,6 @@ export interface AddressInputBannerProps {
11
12
  navHeight?: number;
12
13
  isVisible?: boolean;
13
14
  renderCheckPlans?: (overrides?: CheckPlansProps) => React.ReactNode;
15
+ /** Extra DOM attribute props, spread onto the respective rendered elements. */
16
+ fieldProps?: FieldProps<"title">;
14
17
  }
@@ -141,6 +141,17 @@ describe("AnchoredBottomBanner", () => {
141
141
  expect(container.querySelector(".text-white")).toBeInTheDocument();
142
142
  });
143
143
 
144
+ it("falls back to the accent background with dark text for an unmapped color", () => {
145
+ const { container } = render(
146
+ <AnchoredBottomBanner backgroundColor="orange" ctaButtonLabel="Test" />
147
+ );
148
+ expect(
149
+ container.querySelector(".bg-bg-fill-brand-accent")
150
+ ).toBeInTheDocument();
151
+ expect(container.querySelector(".text-text-primary")).toBeInTheDocument();
152
+ expect(container.querySelector(".text-white")).not.toBeInTheDocument();
153
+ });
154
+
144
155
  // Box shadow
145
156
  it("uses default shadow when boxShadow is not provided", () => {
146
157
  const { container } = render(<AnchoredBottomBanner />);
@@ -568,6 +579,30 @@ describe("AnchoredBottomBanner scheduledMessages", () => {
568
579
  container.querySelector(".bg-bg-fill-brand-accent")
569
580
  ).toBeInTheDocument();
570
581
  });
582
+
583
+ it("falls back to the accent background with dark text for an unmapped active-message color", () => {
584
+ const { container } = render(
585
+ <AnchoredBottomBanner
586
+ backgroundColor="navy"
587
+ scheduledMessages={[
588
+ {
589
+ scheduledStart: "2026-05-18",
590
+ scheduleEnd: "2026-05-20",
591
+ backgroundColor: "orange",
592
+ ctaButtonLabel: "Active message",
593
+ },
594
+ ]}
595
+ />
596
+ );
597
+ expect(
598
+ container.querySelector(".bg-bg-fill-brand-accent")
599
+ ).toBeInTheDocument();
600
+ expect(
601
+ container.querySelector(".bg-bg-fill-inverse")
602
+ ).not.toBeInTheDocument();
603
+ expect(container.querySelector(".text-text-primary")).toBeInTheDocument();
604
+ expect(container.querySelector(".text-white")).not.toBeInTheDocument();
605
+ });
571
606
  });
572
607
 
573
608
  describe("AnchoredBottomBanner scheduling timezone (America/New_York)", () => {
@@ -147,9 +147,10 @@ export const AnchoredBottomBanner: React.FC<AnchoredBottomBannerProps> = ({
147
147
  countdownStartDateTime,
148
148
  countdownEndDateTime,
149
149
  scheduledMessages,
150
+ initialNowMs,
150
151
  onResolved,
151
152
  }) => {
152
- const backGroundColorClasses = {
153
+ const backGroundColorClasses: Record<string, string> = {
153
154
  navy: "bg-bg-fill-inverse",
154
155
  green: "bg-bg-fill-success",
155
156
  blue: "bg-bg-fill-brand-info",
@@ -158,19 +159,27 @@ export const AnchoredBottomBanner: React.FC<AnchoredBottomBannerProps> = ({
158
159
  white: "bg-white",
159
160
  };
160
161
 
161
- const [nowMs, setNowMs] = useState(() => Date.now());
162
+ // Seed from the server-provided timestamp so SSR and the first client render
163
+ // agree; the mount effect below reconciles to the live clock.
164
+ const [nowMs, setNowMs] = useState(() => initialNowMs ?? Date.now());
162
165
  const intervalRef = useRef<number | null>(null);
163
166
 
164
167
  const [hasMounted, setHasMounted] = useState(false);
165
- useEffect(() => setHasMounted(true), []);
168
+ useEffect(() => {
169
+ setHasMounted(true);
170
+ // Reconcile the shared initial timestamp to the client's real time; any
171
+ // resulting boundary change is a normal post-hydration update, not a
172
+ // hydration mismatch.
173
+ setNowMs(Date.now());
174
+ }, []);
166
175
 
167
- // The message active for "now" (display window + daysToShow) wins.
176
+ // The message active for "now" (display window + daysToShow) wins. Seeded from
177
+ // the shared initialNowMs so SSR and the first client render resolve the same
178
+ // message (background + content paint immediately, no post-hydration flip and
179
+ // no structural mismatch); the mount effect then reconciles to the live clock.
168
180
  const activeMessage = useMemo(
169
- () =>
170
- hasMounted
171
- ? scheduledMessages?.find(message => isMessageActive(message, nowMs))
172
- : undefined,
173
- [scheduledMessages, nowMs, hasMounted]
181
+ () => scheduledMessages?.find(message => isMessageActive(message, nowMs)),
182
+ [scheduledMessages, nowMs]
174
183
  );
175
184
  const hasActiveMessage = activeMessage !== undefined;
176
185
 
@@ -311,13 +320,17 @@ export const AnchoredBottomBanner: React.FC<AnchoredBottomBannerProps> = ({
311
320
  const effectiveBackgroundColor = hasActiveMessage
312
321
  ? activeMessage?.backgroundColor
313
322
  : backgroundColor;
314
- const bgClass = effectiveBackgroundColor
323
+ // Unmapped colors fall back to the light accent background, so the contrast
324
+ // decision follows the RESOLVED background (fallback = light) rather than the
325
+ // raw value, keeping CTA/icon text readable.
326
+ const mappedBgClass = effectiveBackgroundColor
315
327
  ? backGroundColorClasses[effectiveBackgroundColor]
316
- : "bg-bg-fill-brand-accent";
328
+ : undefined;
329
+ const bgClass = mappedBgClass || "bg-bg-fill-brand-accent";
317
330
  const isLightBackground =
331
+ !mappedBgClass ||
318
332
  effectiveBackgroundColor === "yellow" ||
319
- effectiveBackgroundColor === "white" ||
320
- !effectiveBackgroundColor;
333
+ effectiveBackgroundColor === "white";
321
334
  const textColorClass = isLightBackground ? "text-text-primary" : "text-white";
322
335
 
323
336
  return (
@@ -348,12 +361,15 @@ export const AnchoredBottomBanner: React.FC<AnchoredBottomBannerProps> = ({
348
361
  className={`${textColorClass} align-text-bottom`}
349
362
  />
350
363
  )}
351
- {hasMounted && countdown.shouldShow && (
352
- <span className="inline-block whitespace-nowrap rounded-lg bg-white px-1 tabular-nums text-text">
364
+ {countdown.shouldShow && (
365
+ <span
366
+ className="inline-block whitespace-nowrap rounded-lg bg-white px-1 tabular-nums text-text"
367
+ suppressHydrationWarning={true}
368
+ >
353
369
  {countdown.text}
354
370
  </span>
355
371
  )}
356
- {hasMounted && countdown.shouldShow ? " " : null}
372
+ {countdown.shouldShow ? " " : null}
357
373
  {effectiveCtaButtonLabel && effectiveCtaButtonLabel}{" "}
358
374
  {effectiveCtaSuffixText && (
359
375
  <span className="ml-0.5">{effectiveCtaSuffixText}</span>
@@ -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;
@@ -5,6 +5,10 @@ import { beforeEach, describe, expect, it, jest } from "@jest/globals";
5
5
  import "@testing-library/jest-dom/jest-globals";
6
6
  import { fireEvent, render, screen } from "@testing-library/react";
7
7
 
8
+ // Keeps mocked DOM elements free of non-DOM props while still surfacing data-* attributes.
9
+ const dataAttrs = (rest: Record<string, unknown>) =>
10
+ Object.fromEntries(Object.entries(rest).filter(([k]) => k.startsWith("data-")));
11
+
8
12
  jest.mock("@shared/components/brand-button", () => ({
9
13
  BrandButton: ({
10
14
  as,
@@ -37,6 +41,7 @@ jest.mock("@shared/components/brand-button", () => ({
37
41
  data-icon-fill={iconFill}
38
42
  className={buttonClassName}
39
43
  onClick={onClick}
44
+ {...dataAttrs(rest)}
40
45
  >
41
46
  {label} {text}
42
47
  </Tag>
@@ -45,9 +50,13 @@ jest.mock("@shared/components/brand-button", () => ({
45
50
  }));
46
51
 
47
52
  jest.mock("@shared/components/button", () => ({
48
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
49
53
  Button: ({ onClick, className, children, ...rest }: any) => (
50
- <button data-testid="core-button" className={className} onClick={onClick}>
54
+ <button
55
+ data-testid="core-button"
56
+ className={className}
57
+ onClick={onClick}
58
+ {...dataAttrs(rest)}
59
+ >
51
60
  {children}
52
61
  </button>
53
62
  ),
@@ -63,6 +72,7 @@ jest.mock("@shared/components/link", () => ({
63
72
  className,
64
73
  title,
65
74
  onClick,
75
+ ...rest
66
76
  }: any) => (
67
77
  <a
68
78
  data-testid="link"
@@ -71,6 +81,7 @@ jest.mock("@shared/components/link", () => ({
71
81
  className={className}
72
82
  title={title}
73
83
  onClick={onClick}
84
+ {...dataAttrs(rest)}
74
85
  >
75
86
  {children}
76
87
  </a>
@@ -97,6 +108,38 @@ describe("Button", () => {
97
108
  jest.clearAllMocks();
98
109
  });
99
110
 
111
+ describe("fieldProps", () => {
112
+ const fieldProps = {
113
+ "data-contentful-entry-id": "entry-1",
114
+ "data-contentful-field-id": "title",
115
+ };
116
+
117
+ it.each([
118
+ ["solid", "brand-button"],
119
+ ["text", "link"],
120
+ ["unstyled", "core-button"],
121
+ ] as const)("spreads fieldProps onto the %s variant", (variant, testId) => {
122
+ render(
123
+ <Button
124
+ buttonLabel="Go"
125
+ href="/page"
126
+ showButtonAs={variant}
127
+ fieldProps={fieldProps}
128
+ />
129
+ );
130
+ const el = screen.getByTestId(testId);
131
+ expect(el).toHaveAttribute("data-contentful-entry-id", "entry-1");
132
+ expect(el).toHaveAttribute("data-contentful-field-id", "title");
133
+ });
134
+
135
+ it("renders no tagging attributes when fieldProps is omitted", () => {
136
+ render(<Button buttonLabel="Go" href="/page" showButtonAs="text" />);
137
+ expect(screen.getByTestId("link")).not.toHaveAttribute(
138
+ "data-contentful-entry-id"
139
+ );
140
+ });
141
+ });
142
+
100
143
  describe("showButtonAs='solid' (default)", () => {
101
144
  it("renders BrandButton as anchor when href is provided", () => {
102
145
  render(<Button buttonLabel="Click" href="/page" />);
@@ -50,6 +50,7 @@ export const Button: React.FC<ButtonProps> = props => {
50
50
  preserveQueryParameters,
51
51
  disableAnimation,
52
52
  animationType = DEFAULT_BUTTON_ANIMATION_TYPE,
53
+ fieldProps,
53
54
  ...rest
54
55
  } = props;
55
56
 
@@ -143,7 +144,12 @@ export const Button: React.FC<ButtonProps> = props => {
143
144
  ? buildPreservedQueryHref(href, currentSearch)
144
145
  : href;
145
146
  let CTA = (
146
- <CoreButton onClick={buttonClick} className={buttonClassName} {...rest} />
147
+ <CoreButton
148
+ onClick={buttonClick}
149
+ className={buttonClassName}
150
+ {...rest}
151
+ {...fieldProps}
152
+ />
147
153
  );
148
154
 
149
155
  switch (showButtonAs) {
@@ -166,6 +172,7 @@ export const Button: React.FC<ButtonProps> = props => {
166
172
  iconFill={iconFill}
167
173
  disableAnimation={disableAnimation}
168
174
  animationType={animationType}
175
+ {...fieldProps}
169
176
  />
170
177
  ) : (
171
178
  <BrandButton
@@ -183,6 +190,7 @@ export const Button: React.FC<ButtonProps> = props => {
183
190
  buttonClassName={brandButtonClassName}
184
191
  disableAnimation={disableAnimation}
185
192
  animationType={animationType}
193
+ {...fieldProps}
186
194
  />
187
195
  );
188
196
  break;
@@ -199,6 +207,7 @@ export const Button: React.FC<ButtonProps> = props => {
199
207
  linkClassName
200
208
  )}
201
209
  onClick={linkClick}
210
+ {...fieldProps}
202
211
  >
203
212
  {matIconName ? (
204
213
  <>
@@ -242,6 +251,7 @@ export const Button: React.FC<ButtonProps> = props => {
242
251
  iconFill={iconFill}
243
252
  disableAnimation={disableAnimation}
244
253
  animationType={animationType}
254
+ {...fieldProps}
245
255
  />
246
256
  ) : (
247
257
  <BrandButton
@@ -258,6 +268,7 @@ export const Button: React.FC<ButtonProps> = props => {
258
268
  iconFill={iconFill}
259
269
  disableAnimation={disableAnimation}
260
270
  animationType={animationType}
271
+ {...fieldProps}
261
272
  />
262
273
  );
263
274
  break;
@@ -287,6 +298,7 @@ export const Button: React.FC<ButtonProps> = props => {
287
298
  buttonClassName
288
299
  )}
289
300
  aria-label={buttonLabel}
301
+ {...fieldProps}
290
302
  >
291
303
  {isLeftIcon && matIconName ? (
292
304
  <MaterialIcon
@@ -45,4 +45,6 @@ export type ButtonProps = {
45
45
  iconSize?: Size;
46
46
  disableAnimation?: boolean;
47
47
  animationType?: AnimationType | AnimationType[];
48
+ /** Extra DOM attribute props, spread onto the rendered link/button element. */
49
+ fieldProps?: Record<string, unknown>;
48
50
  };
@@ -12,8 +12,15 @@ jest.mock("@shared/components/divider", () => ({
12
12
  }));
13
13
 
14
14
  jest.mock("@shared/components/text", () => ({
15
- Text: ({ as: Tag = "span", children, className }: any) => (
16
- <Tag className={className}>{children}</Tag>
15
+ Text: ({ as: Tag = "span", children, className, ...rest }: any) => (
16
+ <Tag
17
+ className={className}
18
+ {...Object.fromEntries(
19
+ Object.entries(rest).filter(([k]) => k.startsWith("data-"))
20
+ )}
21
+ >
22
+ {children}
23
+ </Tag>
17
24
  ),
18
25
  }));
19
26
 
@@ -74,6 +81,62 @@ const defaultProps: FooterProps = {
74
81
  };
75
82
 
76
83
  describe("Footer", () => {
84
+ describe("fieldProps", () => {
85
+ const tag = (field: string) => ({
86
+ "data-contentful-field-id": field,
87
+ });
88
+
89
+ it("spreads body, terms and copyrights fieldProps onto their elements", () => {
90
+ const { container } = render(
91
+ <Footer
92
+ {...defaultProps}
93
+ fieldProps={{
94
+ body: tag("body"),
95
+ terms: tag("termsText"),
96
+ copyrights: tag("copyrightText"),
97
+ }}
98
+ />
99
+ );
100
+ expect(
101
+ screen.getByLabelText("Promotions and disclaimers")
102
+ ).toHaveAttribute("data-contentful-field-id", "body");
103
+ expect(container.querySelector(".terms-text")).toHaveAttribute(
104
+ "data-contentful-field-id",
105
+ "termsText"
106
+ );
107
+ expect(container.querySelector(".copy-rights")).toHaveAttribute(
108
+ "data-contentful-field-id",
109
+ "copyrightText"
110
+ );
111
+ });
112
+
113
+ it("spreads a link group's title fieldProps onto the group title", () => {
114
+ render(
115
+ <Footer
116
+ {...defaultProps}
117
+ links={[
118
+ { ...mockLinks[0], fieldProps: { title: tag("title") } },
119
+ mockLinks[1],
120
+ ]}
121
+ />
122
+ );
123
+ expect(screen.getByText("Products")).toHaveAttribute(
124
+ "data-contentful-field-id",
125
+ "title"
126
+ );
127
+ expect(screen.getByText("Support")).not.toHaveAttribute(
128
+ "data-contentful-field-id"
129
+ );
130
+ });
131
+
132
+ it("renders no tagging attributes when fieldProps is omitted", () => {
133
+ const { container } = render(<Footer {...defaultProps} />);
134
+ expect(
135
+ container.querySelector("[data-contentful-field-id]")
136
+ ).toBeNull();
137
+ });
138
+ });
139
+
77
140
  describe("Rendering", () => {
78
141
  it("renders with all props", () => {
79
142
  render(<Footer {...defaultProps} />);
@@ -13,6 +13,7 @@ export const Footer: React.FC<FooterProps> = ({
13
13
  terms,
14
14
  maxWidth = true,
15
15
  onClick,
16
+ fieldProps,
16
17
  }) => {
17
18
  function footerClick(
18
19
  event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>
@@ -23,7 +24,11 @@ export const Footer: React.FC<FooterProps> = ({
23
24
  return (
24
25
  <div className="component-container bg-bg-inverse text-white">
25
26
  <div className={` ${maxWidth ? "mx-auto max-w-120" : ""} px-5 py-8`}>
26
- <aside className="break-words" aria-label="Promotions and disclaimers">
27
+ <aside
28
+ className="break-words"
29
+ aria-label="Promotions and disclaimers"
30
+ {...fieldProps?.body}
31
+ >
27
32
  {body}
28
33
  </aside>
29
34
  <Divider className="my-8 border-border-inverse lg:my-16" />
@@ -36,7 +41,7 @@ export const Footer: React.FC<FooterProps> = ({
36
41
  className="site-links-group"
37
42
  key={`link-group-${index}`}
38
43
  >
39
- <Text as="div" className="label3">
44
+ <Text as="div" className="label3" {...link?.fieldProps?.title}>
40
45
  {link?.title}
41
46
  </Text>
42
47
  <ul className="flex flex-col">
@@ -61,10 +66,15 @@ export const Footer: React.FC<FooterProps> = ({
61
66
  <div className="py-8">
62
67
  <Divider className="my-8 hidden border-border-inverse md:my-8 md:block" />
63
68
 
64
- <p className="terms-text footnote text-white">
69
+ <p className="terms-text footnote text-white" {...fieldProps?.terms}>
65
70
  &copy; {new Date().getFullYear()} {terms}
66
71
  </p>
67
- <div className="copy-rights whitespace-pre-line">{copyrights}</div>
72
+ <div
73
+ className="copy-rights whitespace-pre-line"
74
+ {...fieldProps?.copyrights}
75
+ >
76
+ {copyrights}
77
+ </div>
68
78
  <nav aria-label="Footer legal">
69
79
  <ul className="bottom-links list-none">
70
80
  {bottomLinks?.map((link: any, index: number) => (
@@ -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 FooterProps = {
4
6
  body: React.ReactNode;
5
7
  links?: Array<any>;
@@ -10,4 +12,6 @@ export type FooterProps = {
10
12
  onClick?: (
11
13
  event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>
12
14
  ) => void;
15
+ /** Extra DOM attribute props, spread onto the respective rendered elements. */
16
+ fieldProps?: FieldProps<"body" | "terms" | "copyrights">;
13
17
  };
@@ -12,8 +12,15 @@ jest.mock("@shared/components/material-icon", () => ({
12
12
  }));
13
13
 
14
14
  jest.mock("@shared/components/text", () => ({
15
- Text: ({ as: Tag = "span", children, className }: any) => (
16
- <Tag className={className}>{children}</Tag>
15
+ Text: ({ as: Tag = "span", children, className, ...rest }: any) => (
16
+ <Tag
17
+ className={className}
18
+ {...Object.fromEntries(
19
+ Object.entries(rest).filter(([k]) => k.startsWith("data-"))
20
+ )}
21
+ >
22
+ {children}
23
+ </Tag>
17
24
  ),
18
25
  }));
19
26
 
@@ -50,6 +57,23 @@ jest.mock("@shared/utils", () => ({
50
57
  }));
51
58
 
52
59
  describe("DesktopLinkGroups", () => {
60
+ it("spreads the group's title fieldProps onto the title element", () => {
61
+ render(
62
+ <DesktopLinkGroups
63
+ anchorName="test"
64
+ link={{
65
+ title: "Shop",
66
+ items: { items: [] },
67
+ fieldProps: { title: { "data-contentful-field-id": "title" } },
68
+ }}
69
+ />
70
+ );
71
+ expect(screen.getByText("Shop")).toHaveAttribute(
72
+ "data-contentful-field-id",
73
+ "title"
74
+ );
75
+ });
76
+
53
77
  it("renders nothing when link is undefined", () => {
54
78
  const { container } = render(
55
79
  <DesktopLinkGroups anchorName="test" link={undefined} />
@@ -5,12 +5,14 @@ import { Text } from "@shared/components/text";
5
5
  import { Button } from "@shared/contentful/blocks/button";
6
6
  import { ButtonProps as ContentfulButtonProps } from "@shared/contentful/blocks/button/types";
7
7
  import { useOutsideClick } from "@shared/hooks/use-outside-click";
8
+ import type { FieldProps } from "@shared/types/field-props";
8
9
  import { cx } from "@shared/utils";
9
10
 
10
11
  type ComponentButtonGroup = {
11
12
  anchorId?: string | null;
12
13
  title?: string | null;
13
14
  items?: { items?: ContentfulButtonProps[] | null } | null;
15
+ fieldProps?: FieldProps<"title">;
14
16
  };
15
17
 
16
18
  type Link = ContentfulButtonProps | ComponentButtonGroup;
@@ -61,7 +63,7 @@ export const DesktopLinkGroups: React.FC<LinkGroupsProps> = ({
61
63
  }
62
64
 
63
65
  // Group
64
- const { anchorId, title, items } = link;
66
+ const { anchorId, title, items, fieldProps } = link;
65
67
  const subMenu = Array.isArray(items?.items) ? items!.items! : [];
66
68
  const fullAnchorName = `--link-anchor-${anchorName}`;
67
69
 
@@ -81,7 +83,11 @@ export const DesktopLinkGroups: React.FC<LinkGroupsProps> = ({
81
83
  key={anchorId}
82
84
  showButtonAs="unstyled"
83
85
  >
84
- <Text as="span" className="group-hover:underline">
86
+ <Text
87
+ as="span"
88
+ className="group-hover:underline"
89
+ {...fieldProps?.title}
90
+ >
85
91
  {title ?? null}
86
92
  </Text>
87
93
  <MaterialIcon