@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.
- package/dist/contentful/index.d.ts +11 -1
- package/dist/contentful/index.esm.js +2 -2
- package/dist/contentful/index.esm.js.map +1 -1
- package/dist/contentful/index.js +2 -2
- package/dist/contentful/index.js.map +1 -1
- package/dist/core.d.ts +1 -1
- package/dist/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/contentful/blocks/address-input-banner/index.test.tsx +13 -0
- package/src/contentful/blocks/address-input-banner/index.tsx +5 -1
- package/src/contentful/blocks/address-input-banner/types.ts +3 -0
- package/src/contentful/blocks/anchored-bottom-banner/index.test.tsx +35 -0
- package/src/contentful/blocks/anchored-bottom-banner/index.tsx +32 -16
- package/src/contentful/blocks/anchored-bottom-banner/types.ts +18 -1
- package/src/contentful/blocks/button/index.test.tsx +45 -2
- package/src/contentful/blocks/button/index.tsx +13 -1
- package/src/contentful/blocks/button/types.ts +2 -0
- package/src/contentful/blocks/footer/index.test.tsx +65 -2
- package/src/contentful/blocks/footer/index.tsx +14 -4
- package/src/contentful/blocks/footer/types.ts +4 -0
- package/src/contentful/blocks/navigation/desktop-link-groups.tsx/index.test.tsx +26 -2
- package/src/contentful/blocks/navigation/desktop-link-groups.tsx/index.tsx +8 -2
- package/src/contentful/blocks/navigation/index.test.tsx +40 -2
- package/src/contentful/blocks/navigation/index.tsx +25 -5
- package/src/contentful/blocks/navigation/mobile-link-groups.tsx/index.test.tsx +14 -0
- package/src/contentful/blocks/navigation/mobile-link-groups.tsx/index.tsx +4 -1
- package/src/contentful/blocks/navigation/types.ts +5 -0
- 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<
|
|
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?: "
|
|
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
|
@@ -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
|
|
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
|
-
|
|
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(() =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
{
|
|
352
|
-
<span
|
|
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
|
-
{
|
|
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?:
|
|
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
|
|
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
|
|
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
|
|
@@ -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
|
|
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
|
|
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
|
© {new Date().getFullYear()} {terms}
|
|
66
71
|
</p>
|
|
67
|
-
<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
|
|
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
|
|
86
|
+
<Text
|
|
87
|
+
as="span"
|
|
88
|
+
className="group-hover:underline"
|
|
89
|
+
{...fieldProps?.title}
|
|
90
|
+
>
|
|
85
91
|
{title ?? null}
|
|
86
92
|
</Text>
|
|
87
93
|
<MaterialIcon
|