@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.
Files changed (35) hide show
  1. package/dist/contentful/index.d.ts +10 -0
  2. package/dist/contentful/index.esm.js +1 -1
  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 +48 -6
  7. package/dist/index.d.ts +48 -6
  8. package/dist/index.esm.js +6 -6
  9. package/dist/index.esm.js.map +1 -1
  10. package/dist/index.js +6 -6
  11. package/dist/index.js.map +1 -1
  12. package/dist/styles.css +1 -1
  13. package/package.json +1 -1
  14. package/src/components/brand-button/index.tsx +2 -2
  15. package/src/components/precisely-address-autocomplete/PreciselyAddressAutocomplete.stories.tsx +69 -0
  16. package/src/components/precisely-address-autocomplete/index.test.tsx +159 -0
  17. package/src/components/precisely-address-autocomplete/index.tsx +219 -0
  18. package/src/components/precisely-address-autocomplete/types.ts +54 -0
  19. package/src/contentful/blocks/callout/index.test.tsx +2 -2
  20. package/src/contentful/blocks/callout/index.tsx +1 -0
  21. package/src/contentful/blocks/cards/blog-card/index.tsx +9 -2
  22. package/src/contentful/blocks/cards/blog-card/types.ts +4 -0
  23. package/src/contentful/blocks/cards/floating-image-card/index.tsx +24 -9
  24. package/src/contentful/blocks/cards/floating-image-card/types.ts +4 -0
  25. package/src/contentful/blocks/cards/full-image-card/index.tsx +2 -1
  26. package/src/contentful/blocks/cards/full-image-card/types.ts +4 -0
  27. package/src/contentful/blocks/cards/simple-card/index.tsx +4 -3
  28. package/src/contentful/blocks/cards/simple-card/types.ts +4 -0
  29. package/src/contentful/blocks/find-kinetic/index.tsx +20 -7
  30. package/src/contentful/blocks/find-kinetic/types.ts +1 -0
  31. package/src/contentful/blocks/image-promo-bar/index.tsx +2 -1
  32. package/src/contentful/blocks/image-promo-bar/types.ts +1 -0
  33. package/src/contentful/blocks/primary-hero/index.tsx +6 -6
  34. package/src/hooks/contentful/use-contentful-rich-text.test.tsx +6 -6
  35. 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-6 by default", () => {
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-6");
264
+ expect(gridHolder?.className).toContain("gap-3");
265
265
  });
266
266
 
267
267
  it("calculates 3 columns for 6 items (divisible by 3)", () => {
@@ -140,6 +140,7 @@ export const Callout: React.FC<CalloutProps> = ({
140
140
  date={blogItem.blogCreationDate}
141
141
  category={blogItem.category}
142
142
  image={blogItem.cover}
143
+ imageAltText={blogItem.imageAltText}
143
144
  asGrid={false}
144
145
  {...widthProps}
145
146
  />
@@ -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={image.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={image.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: "flex w-full justify-start",
12
- right: "flex w-full justify-end",
13
- center: "flex w-full justify-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={image.title ?? title ?? "promo-bar-image"}
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] lg:mb-0",
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={image.title ?? title ?? "card-image"}
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 as="div" className="body1 -mt-1 mb-0 flex-1 text-text md:-mt-2 lg:mt-2">
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 || (cta.buttonIconName ? undefined : "text-with-icon")
124
+ cta.showButtonAs ||
125
+ (cta.buttonIconName ? undefined : "text-with-icon")
112
126
  }
113
127
  iconName={
114
- cta.iconName || (cta.buttonIconName ? undefined : "expand_circle_right")
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
- isCtaOnly && "w-[342px] max-w-full lg:w-[342px]"
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";