@windstream/react-shared-components 0.2.48 → 0.2.50
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 +10 -0
- package/dist/contentful/index.esm.js +1 -1
- 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 +48 -6
- package/dist/index.d.ts +48 -6
- package/dist/index.esm.js +6 -6
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +6 -6
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/components/brand-button/index.tsx +2 -2
- package/src/components/precisely-address-autocomplete/PreciselyAddressAutocomplete.stories.tsx +69 -0
- package/src/components/precisely-address-autocomplete/index.test.tsx +159 -0
- package/src/components/precisely-address-autocomplete/index.tsx +219 -0
- package/src/components/precisely-address-autocomplete/types.ts +54 -0
- package/src/contentful/blocks/callout/index.test.tsx +2 -2
- package/src/contentful/blocks/callout/index.tsx +1 -0
- package/src/contentful/blocks/cards/blog-card/index.tsx +9 -2
- package/src/contentful/blocks/cards/blog-card/types.ts +4 -0
- package/src/contentful/blocks/cards/floating-image-card/index.tsx +24 -9
- package/src/contentful/blocks/cards/floating-image-card/types.ts +4 -0
- package/src/contentful/blocks/cards/full-image-card/index.tsx +2 -1
- package/src/contentful/blocks/cards/full-image-card/types.ts +4 -0
- package/src/contentful/blocks/cards/simple-card/index.tsx +4 -3
- package/src/contentful/blocks/cards/simple-card/types.ts +4 -0
- package/src/contentful/blocks/find-kinetic/index.tsx +20 -7
- package/src/contentful/blocks/find-kinetic/types.ts +1 -0
- package/src/contentful/blocks/image-promo-bar/index.tsx +2 -1
- package/src/contentful/blocks/image-promo-bar/types.ts +1 -0
- package/src/contentful/blocks/primary-hero/index.tsx +6 -6
- package/src/hooks/contentful/use-contentful-rich-text.test.tsx +6 -6
- package/src/index.ts +8 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import "@testing-library/jest-dom";
|
|
2
|
+
|
|
3
|
+
import { AddressAutocomplete } from "./index";
|
|
4
|
+
|
|
5
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
6
|
+
|
|
7
|
+
const suggestions = [
|
|
8
|
+
{
|
|
9
|
+
address: {
|
|
10
|
+
addressLine1: "123 Dale Springs",
|
|
11
|
+
addressLine2: "Apt B",
|
|
12
|
+
city: "Binsport",
|
|
13
|
+
state: "NC",
|
|
14
|
+
postalCode: "17032",
|
|
15
|
+
providerId: "candidate-1",
|
|
16
|
+
},
|
|
17
|
+
mainText: "123 Dale Springs",
|
|
18
|
+
placeId: "place-1",
|
|
19
|
+
provider: "precisely",
|
|
20
|
+
},
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
describe("AddressAutocomplete", () => {
|
|
24
|
+
it("requests suggestions and displays results at the minimum query length", () => {
|
|
25
|
+
const onChange = jest.fn();
|
|
26
|
+
render(
|
|
27
|
+
<AddressAutocomplete suggestions={suggestions} onChange={onChange} />
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const input = screen.getByRole("combobox");
|
|
31
|
+
fireEvent.change(input, { target: { value: "12" } });
|
|
32
|
+
expect(
|
|
33
|
+
screen.queryByTestId("address-autocomplete-suggestions")
|
|
34
|
+
).not.toBeInTheDocument();
|
|
35
|
+
|
|
36
|
+
fireEvent.change(input, { target: { value: "123" } });
|
|
37
|
+
expect(onChange).toHaveBeenLastCalledWith("123");
|
|
38
|
+
expect(
|
|
39
|
+
screen.getByTestId("address-autocomplete-suggestions")
|
|
40
|
+
).toBeInTheDocument();
|
|
41
|
+
expect(screen.getByTestId("address-autocomplete-suggestions")).toHaveClass(
|
|
42
|
+
"max-h-[220px]",
|
|
43
|
+
"overflow-y-auto",
|
|
44
|
+
"p-0",
|
|
45
|
+
"border",
|
|
46
|
+
"border-input-border",
|
|
47
|
+
"bg-bg-surface",
|
|
48
|
+
"shadow-dark-drop"
|
|
49
|
+
);
|
|
50
|
+
expect(
|
|
51
|
+
screen.getByRole("button", { name: /123 Dale Springs/i })
|
|
52
|
+
).toHaveClass("px-4");
|
|
53
|
+
expect(screen.getByRole("combobox").parentElement).toHaveClass("pl-4");
|
|
54
|
+
expect(screen.getAllByText("location_on")[0]).toHaveClass(
|
|
55
|
+
"text-icon-brand"
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("uses the input pin color for each suggestion pin", () => {
|
|
60
|
+
render(
|
|
61
|
+
<AddressAutocomplete
|
|
62
|
+
defaultValue="123"
|
|
63
|
+
suggestions={suggestions}
|
|
64
|
+
prefixIconClassName="text-input-icon-placeholder"
|
|
65
|
+
/>
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
fireEvent.focus(screen.getByRole("combobox"));
|
|
69
|
+
const pins = screen.getAllByText("location_on");
|
|
70
|
+
expect(pins).toHaveLength(2);
|
|
71
|
+
pins.forEach(pin => {
|
|
72
|
+
expect(pin).toHaveClass("text-input-icon-placeholder");
|
|
73
|
+
expect(pin).toHaveClass(
|
|
74
|
+
"inline-flex",
|
|
75
|
+
"items-center",
|
|
76
|
+
"justify-center",
|
|
77
|
+
"leading-none"
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("returns the selected provider address without requesting again", () => {
|
|
83
|
+
const onChange = jest.fn();
|
|
84
|
+
const onSelectAddress = jest.fn();
|
|
85
|
+
render(
|
|
86
|
+
<AddressAutocomplete
|
|
87
|
+
defaultValue="123"
|
|
88
|
+
suggestions={suggestions}
|
|
89
|
+
onChange={onChange}
|
|
90
|
+
onSelectAddress={onSelectAddress}
|
|
91
|
+
/>
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
fireEvent.focus(screen.getByRole("combobox"));
|
|
95
|
+
fireEvent.click(screen.getByRole("button", { name: /123 Dale Springs/i }));
|
|
96
|
+
|
|
97
|
+
expect(onSelectAddress).toHaveBeenCalledWith({
|
|
98
|
+
address: "123 Dale Springs, Apt B, Binsport, NC, 17032",
|
|
99
|
+
unit: "Apt B",
|
|
100
|
+
addressToSend: suggestions[0].address,
|
|
101
|
+
placeId: "place-1",
|
|
102
|
+
});
|
|
103
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
104
|
+
expect(screen.getByRole("combobox")).toHaveValue(
|
|
105
|
+
"123 Dale Springs, Apt B, Binsport, NC, 17032"
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("clears the selected address when the input is emptied", () => {
|
|
110
|
+
const onSelectAddress = jest.fn();
|
|
111
|
+
render(
|
|
112
|
+
<AddressAutocomplete
|
|
113
|
+
defaultValue="123"
|
|
114
|
+
onSelectAddress={onSelectAddress}
|
|
115
|
+
/>
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
fireEvent.change(screen.getByRole("combobox"), {
|
|
119
|
+
target: { value: "" },
|
|
120
|
+
});
|
|
121
|
+
expect(onSelectAddress).toHaveBeenCalledWith();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("focuses and reports load when its provider becomes ready", () => {
|
|
125
|
+
const onLoadProvider = jest.fn();
|
|
126
|
+
const { rerender } = render(
|
|
127
|
+
<AddressAutocomplete isReady={false} onLoadProvider={onLoadProvider} />
|
|
128
|
+
);
|
|
129
|
+
const input = screen.getByRole("combobox");
|
|
130
|
+
|
|
131
|
+
expect(input).toBeDisabled();
|
|
132
|
+
expect(onLoadProvider).not.toHaveBeenCalled();
|
|
133
|
+
|
|
134
|
+
rerender(
|
|
135
|
+
<AddressAutocomplete isReady={true} onLoadProvider={onLoadProvider} />
|
|
136
|
+
);
|
|
137
|
+
expect(input).toHaveFocus();
|
|
138
|
+
expect(onLoadProvider).toHaveBeenCalledTimes(1);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("dismisses suggestions after an outside click", () => {
|
|
142
|
+
render(
|
|
143
|
+
<div>
|
|
144
|
+
<AddressAutocomplete defaultValue="123" suggestions={suggestions} />
|
|
145
|
+
<button type="button">Outside</button>
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
fireEvent.focus(screen.getByRole("combobox"));
|
|
150
|
+
expect(
|
|
151
|
+
screen.getByTestId("address-autocomplete-suggestions")
|
|
152
|
+
).toBeInTheDocument();
|
|
153
|
+
|
|
154
|
+
fireEvent.click(screen.getByRole("button", { name: "Outside" }));
|
|
155
|
+
expect(
|
|
156
|
+
screen.queryByTestId("address-autocomplete-suggestions")
|
|
157
|
+
).not.toBeInTheDocument();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
4
|
+
import type {
|
|
5
|
+
AddressAutocompleteAddress,
|
|
6
|
+
AddressAutocompleteProps,
|
|
7
|
+
AddressAutocompleteSuggestion,
|
|
8
|
+
} from "./types";
|
|
9
|
+
|
|
10
|
+
import { Input } from "@shared/components/input";
|
|
11
|
+
import { MaterialIcon } from "@shared/components/material-icon";
|
|
12
|
+
import { useOutsideClick } from "@shared/hooks/use-outside-click";
|
|
13
|
+
import { cx } from "@shared/utils";
|
|
14
|
+
|
|
15
|
+
const formatSuggestion = <TAddress extends AddressAutocompleteAddress>(
|
|
16
|
+
suggestion: AddressAutocompleteSuggestion<TAddress>
|
|
17
|
+
) => {
|
|
18
|
+
const { address, mainText, postalCode: suggestionPostalCode } = suggestion;
|
|
19
|
+
if (!address) {
|
|
20
|
+
return { primaryText: mainText, secondaryText: "", value: mainText };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const primaryText = [address.addressLine1, address.addressLine2]
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
.join(", ");
|
|
26
|
+
const secondaryText = [
|
|
27
|
+
address.city,
|
|
28
|
+
address.state,
|
|
29
|
+
address.postalCode || suggestionPostalCode,
|
|
30
|
+
]
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.join(", ");
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
primaryText: primaryText || mainText,
|
|
36
|
+
secondaryText,
|
|
37
|
+
value: [primaryText, secondaryText].filter(Boolean).join(", ") || mainText,
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const AddressAutocomplete = <
|
|
42
|
+
TAddress extends AddressAutocompleteAddress = AddressAutocompleteAddress,
|
|
43
|
+
>({
|
|
44
|
+
className,
|
|
45
|
+
containerClassName,
|
|
46
|
+
defaultValue = "",
|
|
47
|
+
errorText,
|
|
48
|
+
fixedPositionedSuggestions = false,
|
|
49
|
+
focusOnLoad = true,
|
|
50
|
+
hasError,
|
|
51
|
+
inputContainerClassName,
|
|
52
|
+
isIframe = false,
|
|
53
|
+
isLoading = false,
|
|
54
|
+
isReady = true,
|
|
55
|
+
mapPinIcon = true,
|
|
56
|
+
minQueryLength = 3,
|
|
57
|
+
onChange,
|
|
58
|
+
onFocus,
|
|
59
|
+
onLoadProvider,
|
|
60
|
+
onSelectAddress,
|
|
61
|
+
onSuggestionsChange,
|
|
62
|
+
optionsContainerClassName,
|
|
63
|
+
prefixIconClassName = "text-icon-brand",
|
|
64
|
+
suggestions = [],
|
|
65
|
+
value: controlledValue,
|
|
66
|
+
...inputProps
|
|
67
|
+
}: AddressAutocompleteProps<TAddress>) => {
|
|
68
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
69
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
70
|
+
const suggestionsId = useId();
|
|
71
|
+
const [value, setValue] = useState(controlledValue ?? defaultValue);
|
|
72
|
+
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
73
|
+
const pinIconClassName = cx(
|
|
74
|
+
"inline-flex items-center justify-center leading-none",
|
|
75
|
+
prefixIconClassName
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (controlledValue !== undefined) setValue(controlledValue);
|
|
80
|
+
}, [controlledValue]);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (isReady && focusOnLoad) inputRef.current?.focus();
|
|
84
|
+
}, [focusOnLoad, isReady]);
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (isReady) onLoadProvider?.();
|
|
88
|
+
}, [isReady, onLoadProvider]);
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
onSuggestionsChange?.(suggestions);
|
|
92
|
+
}, [onSuggestionsChange, suggestions]);
|
|
93
|
+
|
|
94
|
+
useOutsideClick(rootRef, () => setShowSuggestions(false));
|
|
95
|
+
|
|
96
|
+
const selectSuggestion = useCallback(
|
|
97
|
+
(suggestion: AddressAutocompleteSuggestion<TAddress>) => {
|
|
98
|
+
if (!suggestion.address) return;
|
|
99
|
+
|
|
100
|
+
const formatted = formatSuggestion(suggestion);
|
|
101
|
+
setValue(formatted.value);
|
|
102
|
+
setShowSuggestions(false);
|
|
103
|
+
onSelectAddress?.({
|
|
104
|
+
address: formatted.value,
|
|
105
|
+
unit: suggestion.address.addressLine2 || "",
|
|
106
|
+
addressToSend: suggestion.address,
|
|
107
|
+
placeId: suggestion.placeId || "",
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
[onSelectAddress]
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const hasSuggestions =
|
|
114
|
+
showSuggestions &&
|
|
115
|
+
value.length >= minQueryLength &&
|
|
116
|
+
!isLoading &&
|
|
117
|
+
suggestions.length > 0;
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<div
|
|
121
|
+
ref={rootRef}
|
|
122
|
+
className={cx("relative min-w-[300px] text-text", containerClassName)}
|
|
123
|
+
>
|
|
124
|
+
<Input
|
|
125
|
+
{...inputProps}
|
|
126
|
+
ref={inputRef}
|
|
127
|
+
size="medium"
|
|
128
|
+
value={value}
|
|
129
|
+
className={cx(
|
|
130
|
+
"mx-auto w-full bg-transparent pl-2 placeholder:text-input-text-placeholder",
|
|
131
|
+
"body2",
|
|
132
|
+
className
|
|
133
|
+
)}
|
|
134
|
+
containerClassName={cx("pl-4", inputContainerClassName)}
|
|
135
|
+
loading={!isReady}
|
|
136
|
+
onChange={event => {
|
|
137
|
+
const nextValue = event.target.value;
|
|
138
|
+
setValue(nextValue);
|
|
139
|
+
setShowSuggestions(nextValue.length >= minQueryLength);
|
|
140
|
+
if (!nextValue) onSelectAddress?.();
|
|
141
|
+
onChange?.(nextValue);
|
|
142
|
+
}}
|
|
143
|
+
onFocus={event => {
|
|
144
|
+
setShowSuggestions(true);
|
|
145
|
+
onFocus?.(event);
|
|
146
|
+
}}
|
|
147
|
+
autoComplete="off"
|
|
148
|
+
role="combobox"
|
|
149
|
+
aria-autocomplete="list"
|
|
150
|
+
aria-controls={suggestionsId}
|
|
151
|
+
aria-expanded={hasSuggestions}
|
|
152
|
+
data-cy="address-autocomplete-input"
|
|
153
|
+
data-testid="address-autocomplete-input"
|
|
154
|
+
prefixIconName={mapPinIcon ? "location_on" : undefined}
|
|
155
|
+
prefixIconFill={true}
|
|
156
|
+
prefixIconClassName={pinIconClassName}
|
|
157
|
+
errorText={errorText}
|
|
158
|
+
state={errorText || hasError ? "error" : inputProps.state}
|
|
159
|
+
hasError={hasError}
|
|
160
|
+
/>
|
|
161
|
+
|
|
162
|
+
{hasSuggestions && (
|
|
163
|
+
<ul
|
|
164
|
+
id={suggestionsId}
|
|
165
|
+
role="listbox"
|
|
166
|
+
data-cy="address-autocomplete-suggestions"
|
|
167
|
+
data-testid="address-autocomplete-suggestions"
|
|
168
|
+
className={cx(
|
|
169
|
+
"shadow-dark-drop absolute top-full z-20 m-0 mt-1 w-full list-none overflow-y-auto overscroll-contain rounded-lg border border-input-border bg-bg-surface p-0",
|
|
170
|
+
isIframe ? "max-h-[117px]" : "max-h-[220px]",
|
|
171
|
+
fixedPositionedSuggestions &&
|
|
172
|
+
(isIframe
|
|
173
|
+
? "fixed z-50 max-h-[117px]"
|
|
174
|
+
: "fixed z-50 max-h-[220px]"),
|
|
175
|
+
optionsContainerClassName
|
|
176
|
+
)}
|
|
177
|
+
>
|
|
178
|
+
{suggestions.map((suggestion, index) => {
|
|
179
|
+
const { primaryText, secondaryText } = formatSuggestion(suggestion);
|
|
180
|
+
return (
|
|
181
|
+
<li
|
|
182
|
+
role="option"
|
|
183
|
+
aria-selected="false"
|
|
184
|
+
key={suggestion.placeId || `${suggestion.mainText}-${index}`}
|
|
185
|
+
>
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
className="flex w-full items-center px-4 py-4 text-left hover:bg-bg-surface-secondary"
|
|
189
|
+
onClick={() => selectSuggestion(suggestion)}
|
|
190
|
+
>
|
|
191
|
+
<MaterialIcon
|
|
192
|
+
fill={1}
|
|
193
|
+
name="location_on"
|
|
194
|
+
size={24}
|
|
195
|
+
weight="200"
|
|
196
|
+
className={cx("mr-4 min-w-[24px]", pinIconClassName)}
|
|
197
|
+
/>
|
|
198
|
+
<span>
|
|
199
|
+
<span>{primaryText}</span>
|
|
200
|
+
{secondaryText && (
|
|
201
|
+
<small className="ml-1">{secondaryText}</small>
|
|
202
|
+
)}
|
|
203
|
+
</span>
|
|
204
|
+
</button>
|
|
205
|
+
</li>
|
|
206
|
+
);
|
|
207
|
+
})}
|
|
208
|
+
</ul>
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
export type {
|
|
215
|
+
AddressAutocompleteAddress,
|
|
216
|
+
AddressAutocompleteProps,
|
|
217
|
+
AddressAutocompleteResult,
|
|
218
|
+
AddressAutocompleteSuggestion,
|
|
219
|
+
} from "./types";
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { InputFieldProps } from "@shared/components/input";
|
|
2
|
+
|
|
3
|
+
export type AddressAutocompleteAddress = {
|
|
4
|
+
addressLine1?: string;
|
|
5
|
+
addressLine2?: string | null;
|
|
6
|
+
city?: string;
|
|
7
|
+
state?: string;
|
|
8
|
+
postalCode?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type AddressAutocompleteSuggestion<
|
|
12
|
+
TAddress extends AddressAutocompleteAddress = AddressAutocompleteAddress,
|
|
13
|
+
> = {
|
|
14
|
+
address?: TAddress;
|
|
15
|
+
mainText: string;
|
|
16
|
+
placeId?: string;
|
|
17
|
+
postalCode?: string;
|
|
18
|
+
provider?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type AddressAutocompleteResult<
|
|
22
|
+
TAddress extends AddressAutocompleteAddress = AddressAutocompleteAddress,
|
|
23
|
+
> = {
|
|
24
|
+
address: string;
|
|
25
|
+
unit: string;
|
|
26
|
+
addressToSend: TAddress;
|
|
27
|
+
placeId: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type AddressAutocompleteProps<
|
|
31
|
+
TAddress extends AddressAutocompleteAddress = AddressAutocompleteAddress,
|
|
32
|
+
> = Omit<
|
|
33
|
+
InputFieldProps,
|
|
34
|
+
"defaultValue" | "loading" | "onChange" | "size" | "value"
|
|
35
|
+
> & {
|
|
36
|
+
value?: string;
|
|
37
|
+
defaultValue?: string;
|
|
38
|
+
suggestions?: AddressAutocompleteSuggestion<TAddress>[];
|
|
39
|
+
isLoading?: boolean;
|
|
40
|
+
isReady?: boolean;
|
|
41
|
+
focusOnLoad?: boolean;
|
|
42
|
+
minQueryLength?: number;
|
|
43
|
+
mapPinIcon?: boolean;
|
|
44
|
+
fixedPositionedSuggestions?: boolean;
|
|
45
|
+
isIframe?: boolean;
|
|
46
|
+
inputContainerClassName?: string;
|
|
47
|
+
optionsContainerClassName?: string;
|
|
48
|
+
onChange?: (value: string) => void;
|
|
49
|
+
onSelectAddress?: (result?: AddressAutocompleteResult<TAddress>) => void;
|
|
50
|
+
onSuggestionsChange?: (
|
|
51
|
+
suggestions: AddressAutocompleteSuggestion<TAddress>[]
|
|
52
|
+
) => void;
|
|
53
|
+
onLoadProvider?: () => void;
|
|
54
|
+
};
|
|
@@ -258,10 +258,10 @@ describe("Callout", () => {
|
|
|
258
258
|
expect(gridHolder?.className).not.toContain("gap-6");
|
|
259
259
|
});
|
|
260
260
|
|
|
261
|
-
it("has gap-
|
|
261
|
+
it("has gap-3 by default", () => {
|
|
262
262
|
const { container } = render(<Callout {...defaultProps} />);
|
|
263
263
|
const gridHolder = container.querySelector(".card-holder");
|
|
264
|
-
expect(gridHolder?.className).toContain("gap-
|
|
264
|
+
expect(gridHolder?.className).toContain("gap-3");
|
|
265
265
|
});
|
|
266
266
|
|
|
267
267
|
it("calculates 3 columns for 6 items (divisible by 3)", () => {
|
|
@@ -18,10 +18,17 @@ export const BlogCard: React.FC<BlogCardProps> = ({
|
|
|
18
18
|
date,
|
|
19
19
|
category,
|
|
20
20
|
image,
|
|
21
|
+
imageAltText,
|
|
21
22
|
imageComponent: ImageComponent,
|
|
22
23
|
asGrid = true,
|
|
23
24
|
index,
|
|
24
25
|
}: BlogCardProps) => {
|
|
26
|
+
const imageAlt =
|
|
27
|
+
imageAltText ??
|
|
28
|
+
image?.description ??
|
|
29
|
+
image?.alt ??
|
|
30
|
+
title ??
|
|
31
|
+
"blog-card-image";
|
|
25
32
|
const parentClassName = asGrid
|
|
26
33
|
? "group relative flex h-full flex-col overflow-hidden rounded-card-lg bg-white shadow-drop transition-all duration-200 hover:-translate-y-0.5 hover:shadow-cardDrop"
|
|
27
34
|
: `callout-card group relative w-full flex h-full flex-col overflow-hidden`;
|
|
@@ -40,7 +47,7 @@ export const BlogCard: React.FC<BlogCardProps> = ({
|
|
|
40
47
|
ImageComponent ? (
|
|
41
48
|
<ImageComponent
|
|
42
49
|
src={image.src}
|
|
43
|
-
alt={
|
|
50
|
+
alt={imageAlt}
|
|
44
51
|
width={image.width}
|
|
45
52
|
height={image.height}
|
|
46
53
|
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
|
|
@@ -49,7 +56,7 @@ export const BlogCard: React.FC<BlogCardProps> = ({
|
|
|
49
56
|
) : (
|
|
50
57
|
<img
|
|
51
58
|
src={image.src}
|
|
52
|
-
alt={
|
|
59
|
+
alt={imageAlt}
|
|
53
60
|
width={image.width}
|
|
54
61
|
height={image.height}
|
|
55
62
|
loading="lazy"
|
|
@@ -21,9 +21,13 @@ export interface BlogCardProps {
|
|
|
21
21
|
image?: {
|
|
22
22
|
src: string;
|
|
23
23
|
alt: string;
|
|
24
|
+
/** Contentful asset description — used as the alt-text fallback. */
|
|
25
|
+
description?: string;
|
|
24
26
|
width: number;
|
|
25
27
|
height: number;
|
|
26
28
|
};
|
|
29
|
+
/** Explicit image alt text; falls back to the image description then alt. */
|
|
30
|
+
imageAltText?: string;
|
|
27
31
|
/** Pass next/image's Image component here to enable image optimization */
|
|
28
32
|
imageComponent?: React.ComponentType<BlogCardImageProps>;
|
|
29
33
|
asGrid?: boolean;
|
|
@@ -7,10 +7,11 @@ import { NextImage } from "@shared/components/next-image";
|
|
|
7
7
|
import { Text } from "@shared/components/text";
|
|
8
8
|
import { cx } from "@shared/utils";
|
|
9
9
|
|
|
10
|
+
// Block (full-width) on mobile so the button fills the card; flex alignment only on desktop.
|
|
10
11
|
const alignmentClasses: Record<string, string> = {
|
|
11
|
-
left: "
|
|
12
|
-
right: "
|
|
13
|
-
center: "
|
|
12
|
+
left: "w-full lg:flex lg:justify-start",
|
|
13
|
+
right: "w-full lg:flex lg:justify-end",
|
|
14
|
+
center: "w-full lg:flex lg:justify-center",
|
|
14
15
|
};
|
|
15
16
|
|
|
16
17
|
export const FloatingImageCard: React.FC<FloatingImageCardProps> = ({
|
|
@@ -28,6 +29,7 @@ export const FloatingImageCard: React.FC<FloatingImageCardProps> = ({
|
|
|
28
29
|
imageLink,
|
|
29
30
|
imageClassName,
|
|
30
31
|
className,
|
|
32
|
+
imageAltText,
|
|
31
33
|
isPromoBarImg = false,
|
|
32
34
|
shadow = false,
|
|
33
35
|
disableAnimation,
|
|
@@ -50,7 +52,13 @@ export const FloatingImageCard: React.FC<FloatingImageCardProps> = ({
|
|
|
50
52
|
return image?.href ? (
|
|
51
53
|
<NextImage
|
|
52
54
|
src={image.href}
|
|
53
|
-
alt={
|
|
55
|
+
alt={
|
|
56
|
+
imageAltText ??
|
|
57
|
+
image.description ??
|
|
58
|
+
image.title ??
|
|
59
|
+
title ??
|
|
60
|
+
"promo-bar-image"
|
|
61
|
+
}
|
|
54
62
|
width={148}
|
|
55
63
|
height={40}
|
|
56
64
|
onClick={handleImageClick}
|
|
@@ -73,13 +81,15 @@ export const FloatingImageCard: React.FC<FloatingImageCardProps> = ({
|
|
|
73
81
|
{image?.href ? (
|
|
74
82
|
<div
|
|
75
83
|
className={cx(
|
|
76
|
-
"relative h-[233px] w-full overflow-hidden sm:mb-1 md:h-[274px] md:flex-none lg:h-[330px]
|
|
84
|
+
"relative h-[233px] w-full overflow-hidden sm:mb-1 md:h-[274px] md:flex-none lg:mb-0 lg:h-[330px]",
|
|
77
85
|
imageClassName
|
|
78
86
|
)}
|
|
79
87
|
>
|
|
80
88
|
<NextImage
|
|
81
89
|
src={image.href}
|
|
82
|
-
alt={
|
|
90
|
+
alt={
|
|
91
|
+
imageAltText ?? image.description ?? image.title ?? "card-image"
|
|
92
|
+
}
|
|
83
93
|
width={image.width || 400}
|
|
84
94
|
height={image.height || 330}
|
|
85
95
|
onClick={handleImageClick}
|
|
@@ -98,7 +108,10 @@ export const FloatingImageCard: React.FC<FloatingImageCardProps> = ({
|
|
|
98
108
|
) : null}
|
|
99
109
|
|
|
100
110
|
{body ? (
|
|
101
|
-
<Text
|
|
111
|
+
<Text
|
|
112
|
+
as="div"
|
|
113
|
+
className="body1 -mt-1 mb-0 flex-1 text-text md:-mt-2 lg:mt-2"
|
|
114
|
+
>
|
|
102
115
|
{body}
|
|
103
116
|
</Text>
|
|
104
117
|
) : null}
|
|
@@ -108,10 +121,12 @@ export const FloatingImageCard: React.FC<FloatingImageCardProps> = ({
|
|
|
108
121
|
<Button
|
|
109
122
|
{...cta}
|
|
110
123
|
showButtonAs={
|
|
111
|
-
cta.showButtonAs ||
|
|
124
|
+
cta.showButtonAs ||
|
|
125
|
+
(cta.buttonIconName ? undefined : "text-with-icon")
|
|
112
126
|
}
|
|
113
127
|
iconName={
|
|
114
|
-
cta.iconName ||
|
|
128
|
+
cta.iconName ||
|
|
129
|
+
(cta.buttonIconName ? undefined : "expand_circle_right")
|
|
115
130
|
}
|
|
116
131
|
iconFill={cta.iconFill ?? 1}
|
|
117
132
|
// Button animates ONLY when BOTH are explicitly enabled:
|
|
@@ -4,6 +4,8 @@ import type { ButtonProps } from "../../button/types";
|
|
|
4
4
|
export type FloatingImageAsset = {
|
|
5
5
|
href: string;
|
|
6
6
|
title?: string;
|
|
7
|
+
/** Contentful asset description — used as the alt-text fallback. */
|
|
8
|
+
description?: string;
|
|
7
9
|
width?: number;
|
|
8
10
|
height?: number;
|
|
9
11
|
};
|
|
@@ -15,6 +17,8 @@ export type FloatingImageCardItem = {
|
|
|
15
17
|
cta?: ButtonProps;
|
|
16
18
|
ctaAlignment?: "left" | "right" | "center";
|
|
17
19
|
image?: FloatingImageAsset;
|
|
20
|
+
/** Explicit image alt text; falls back to the image asset description. */
|
|
21
|
+
imageAltText?: string;
|
|
18
22
|
imageLink?: string;
|
|
19
23
|
imageClassName?: string;
|
|
20
24
|
className?: string;
|
|
@@ -18,6 +18,7 @@ export const FullImageCard: React.FC<FullImageCardProps> = ({
|
|
|
18
18
|
description,
|
|
19
19
|
caption,
|
|
20
20
|
image,
|
|
21
|
+
imageAltText,
|
|
21
22
|
cta,
|
|
22
23
|
link,
|
|
23
24
|
className,
|
|
@@ -35,7 +36,7 @@ export const FullImageCard: React.FC<FullImageCardProps> = ({
|
|
|
35
36
|
const img = (
|
|
36
37
|
<NextImage
|
|
37
38
|
src={image.href}
|
|
38
|
-
alt={image.title ?? title ?? "card-image"}
|
|
39
|
+
alt={imageAltText ?? image.description ?? image.title ?? title ?? "card-image"}
|
|
39
40
|
width={image.width || 400}
|
|
40
41
|
height={image.height || 280}
|
|
41
42
|
className="w-full"
|
|
@@ -3,6 +3,8 @@ import type { ButtonProps } from "../../button/types";
|
|
|
3
3
|
export type FullImageAsset = {
|
|
4
4
|
href: string;
|
|
5
5
|
title?: string;
|
|
6
|
+
/** Contentful asset description — used as the alt-text fallback. */
|
|
7
|
+
description?: string;
|
|
6
8
|
width?: number;
|
|
7
9
|
height?: number;
|
|
8
10
|
};
|
|
@@ -15,6 +17,8 @@ export type FullImageCardItem = {
|
|
|
15
17
|
cta?: ButtonProps;
|
|
16
18
|
link?: ButtonProps;
|
|
17
19
|
image?: FullImageAsset;
|
|
20
|
+
/** Explicit image alt text; falls back to the image asset description. */
|
|
21
|
+
imageAltText?: string;
|
|
18
22
|
className?: string;
|
|
19
23
|
contentClassName?: string;
|
|
20
24
|
imageClassName?: string;
|
|
@@ -97,7 +97,7 @@ export const SimpleCard: React.FC<SimpleCardProps> = ({
|
|
|
97
97
|
>
|
|
98
98
|
<NextImage
|
|
99
99
|
src={card.image}
|
|
100
|
-
alt={card.imageAlt ?? card.title ?? "card-image"}
|
|
100
|
+
alt={card.imageAltText ?? card.imageDescription ?? card.imageAlt ?? card.title ?? "card-image"}
|
|
101
101
|
width={card.imageWidth || 400}
|
|
102
102
|
height={card.imageHeight || 280}
|
|
103
103
|
className={cx("w-full", !margin && "rounded-t-3xl")}
|
|
@@ -124,7 +124,7 @@ export const SimpleCard: React.FC<SimpleCardProps> = ({
|
|
|
124
124
|
>
|
|
125
125
|
<NextImage
|
|
126
126
|
src={card.image}
|
|
127
|
-
alt={card.imageAlt ?? card.title ?? "card-icon"}
|
|
127
|
+
alt={card.imageAltText ?? card.imageDescription ?? card.imageAlt ?? card.title ?? "card-icon"}
|
|
128
128
|
width={imgWidth}
|
|
129
129
|
height={imgHeight}
|
|
130
130
|
className={cx(
|
|
@@ -331,7 +331,8 @@ export const SimpleCard: React.FC<SimpleCardProps> = ({
|
|
|
331
331
|
fullWidth={true}
|
|
332
332
|
buttonClassName={cx(
|
|
333
333
|
card.cta.buttonClassName,
|
|
334
|
-
|
|
334
|
+
// Narrower padding so long labels still fit the fixed width.
|
|
335
|
+
isCtaOnly && "w-[342px] max-w-full px-4 lg:w-[342px]"
|
|
335
336
|
)}
|
|
336
337
|
/>
|
|
337
338
|
</div>
|
|
@@ -17,6 +17,10 @@ export type Item = {
|
|
|
17
17
|
title?: string;
|
|
18
18
|
image?: string;
|
|
19
19
|
imageAlt?: string;
|
|
20
|
+
/** Explicit image alt text; takes priority over the description fallback. */
|
|
21
|
+
imageAltText?: string;
|
|
22
|
+
/** Contentful asset description — used as the alt-text fallback. */
|
|
23
|
+
imageDescription?: string;
|
|
20
24
|
imageWidth?: number;
|
|
21
25
|
imageHeight?: number;
|
|
22
26
|
imageAlignment?: "left" | "right" | "center";
|